diff --git a/client/public/brands/airtrail.svg b/client/public/brands/airtrail.svg
new file mode 100644
index 000000000..6349a0370
--- /dev/null
+++ b/client/public/brands/airtrail.svg
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/client/public/brands/dawarich.svg b/client/public/brands/dawarich.svg
new file mode 100644
index 000000000..cfe2843ee
--- /dev/null
+++ b/client/public/brands/dawarich.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index 8313aaee9..595451215 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -21,6 +21,7 @@ import {
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
type ResetPasswordRequest, type ChangePasswordRequest,
type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest,
+ type ApiTokenCreateRequest, type PublicApiScope,
type TripAddMemberRequest, type TripTransferOwnershipRequest,
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
@@ -342,7 +343,10 @@ export const authApi = {
// and a key of the wrong kind is refused like one that does not exist.
apiKeys: {
list: () => apiClient.get('/auth/api-tokens').then(r => r.data),
- create: (name: string) => apiClient.post('/auth/api-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data),
+ // `scopes` narrows what the key may read (#2279). Omitted means everything,
+ // which is what every key minted before scopes existed still does.
+ create: (name: string, scopes?: PublicApiScope[]) =>
+ apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data),
delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data),
},
passkey: {
diff --git a/client/src/api/dawarich.ts b/client/src/api/dawarich.ts
new file mode 100644
index 000000000..d2c3ff295
--- /dev/null
+++ b/client/src/api/dawarich.ts
@@ -0,0 +1,83 @@
+import apiClient from './client'
+import type {
+ DawarichAccept,
+ DawarichAcceptResult,
+ DawarichAtlasSuggestions,
+ DawarichBucketScan,
+ DawarichConnection,
+ DawarichSettings,
+ DawarichStatus,
+ DawarichSuggestion,
+ DawarichSuggestionList,
+ DawarichTrack,
+} from '@trek/shared'
+
+/**
+ * The Dawarich surface, in its own file rather than in the `client.ts` god
+ * module. Types come from the shared contracts, so a server change that the
+ * client has not caught up with is a typecheck failure rather than a runtime
+ * surprise.
+ */
+export const dawarichApi = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ getSettings: (): Promise =>
+ apiClient.get('/integrations/dawarich/settings').then(r => r.data),
+ saveSettings: (data: DawarichSettings): Promise<{
+ success: boolean
+ /** The server's own sentence, English — the fallback when the code below is unknown here. */
+ warning?: string
+ /** What the warning is about, so the UI can say it in the reader's language. */
+ warningCode?: string
+ /** The address it resolved to, for the private-IP warning. */
+ warningIp?: string
+ }> =>
+ apiClient.put('/integrations/dawarich/settings', data).then(r => r.data),
+ disconnect: (): Promise<{ success: boolean }> =>
+ apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
+ test: (data: Partial): Promise =>
+ apiClient.post('/integrations/dawarich/test', data).then(r => r.data),
+ syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
+ apiClient.post('/integrations/dawarich/sync').then(r => r.data),
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ listSuggestions: (params?: { tripId?: number; state?: string }): Promise =>
+ apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
+ accept: (id: number, body: DawarichAccept): Promise =>
+ apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
+ setState: (id: number, state: 'new' | 'dismissed'): Promise =>
+ apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
+
+ // ── Bucket list ────────────────────────────────────────────────────────────
+ scanBucketList: (): Promise =>
+ apiClient.post('/integrations/dawarich/bucket-list/scan').then(r => r.data),
+ confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
+ apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
+ clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
+ apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ atlasSuggestions: (from: string, to: string): Promise =>
+ apiClient.get('/integrations/dawarich/atlas/suggestions', { params: { from, to } }).then(r => r.data),
+ acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
+ apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
+
+ // ── Track overlay ──────────────────────────────────────────────────────────
+ //
+ // Recorded points are bare instants, so the server cannot know which day they
+ // belong to without being told whose day is meant. The browser is the only
+ // party that knows, so it says so — otherwise an evening east of UTC lands on
+ // the wrong day of the trip.
+ tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise =>
+ apiClient
+ .get(`/integrations/dawarich/trips/${tripId}/track`, { params: { ...params, offset: localOffsetMinutes() }, signal })
+ .then(r => r.data),
+ windowTrack: (from: string, to: string, signal?: AbortSignal): Promise =>
+ apiClient
+ .get('/integrations/dawarich/track', { params: { from, to, offset: localOffsetMinutes() }, signal })
+ .then(r => r.data),
+}
+
+/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
+function localOffsetMinutes(): number {
+ return -new Date().getTimezoneOffset()
+}
diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx
index c9b041a88..2deda0596 100644
--- a/client/src/components/Admin/AddonManager.tsx
+++ b/client/src/components/Admin/AddonManager.tsx
@@ -7,6 +7,8 @@ import { useToast } from '../shared/Toast'
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
+import DawarichIcon from '../shared/DawarichIcon'
+import AirTrailIcon from '../shared/AirTrailIcon'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -15,6 +17,9 @@ import AddonSubRow from './AddonSubRow'
// used to land on the fallback.
const ICON_MAP = {
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
+ // Dawarich ships a brand mark rather than a lucide name, the same way the photo
+ // providers below do. Keyed by the string in the addons table.
+ Dawarich: DawarichIcon,
}
function ImmichIcon({ size = 14 }: { size?: number }) {
@@ -59,9 +64,20 @@ interface ProviderOption {
interface AddonIconProps {
name: string
size?: number
+ /** A switched-off addon greys its icon out, brand marks included. */
+ enabled?: boolean
}
-function AddonIcon({ name, size = 20 }: AddonIconProps) {
+function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
+ // The brand marks are badges, not glyphs: they fill the framed slot instead of
+ // sitting in the middle of it at glyph size, and they grey out when the addon
+ // is off — the lucide glyphs get that from the slot's text colour, an
+ // has to be told.
+ if (name === 'Dawarich') return
+ // 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
+ // means every existing install still carries it — so the brand is keyed on
+ // that rather than on a new name no row would ever have.
+ if (name === 'Plane') return
const Icon = ICON_MAP[name] || Puzzle
return
}
@@ -212,7 +228,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return (
}
+ icon={}
name={label.name}
description={label.description}
enabled={addon.enabled}
diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx
index 99e68f1c6..10760615a 100644
--- a/client/src/components/Admin/AddonTile.tsx
+++ b/client/src/components/Admin/AddonTile.tsx
@@ -44,7 +44,9 @@ export default function AddonTile({
>
diff --git a/client/src/components/Atlas/useGlassGlare.ts b/client/src/components/Atlas/useGlassGlare.ts
new file mode 100644
index 000000000..540401de7
--- /dev/null
+++ b/client/src/components/Atlas/useGlassGlare.ts
@@ -0,0 +1,48 @@
+import { useRef, type RefObject } from 'react'
+
+export interface GlassGlare {
+ /** Goes on the panel itself — the glare is measured against its box. */
+ panelRef: RefObject
+ /** The soft light under the cursor. */
+ glareRef: RefObject
+ /** The lit stretch of border around it. */
+ borderGlareRef: RefObject
+ onMouseMove: (e: { clientX: number; clientY: number }) => void
+ onMouseLeave: () => void
+}
+
+/**
+ * The Atlas panels' liquid-glass hover: a pool of light under the cursor and the
+ * piece of border nearest to it lighting up.
+ *
+ * Extracted so a second panel beside the first one behaves identically. Two
+ * hand-copied versions of a gradient are two versions that drift, and the whole
+ * point of the effect is that the panels look like one material.
+ *
+ * Written straight to the DOM rather than through state: this runs on every
+ * mousemove, and a re-render per frame would cost more than the effect is worth.
+ */
+export function useGlassGlare(dark: boolean): GlassGlare {
+ const panelRef = useRef(null)
+ const glareRef = useRef(null)
+ const borderGlareRef = useRef(null)
+
+ const onMouseMove = (e: { clientX: number; clientY: number }): void => {
+ if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
+ const rect = panelRef.current.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+ glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
+ glareRef.current.style.opacity = '1'
+ borderGlareRef.current.style.opacity = '1'
+ borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
+ borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
+ }
+
+ const onMouseLeave = (): void => {
+ if (glareRef.current) glareRef.current.style.opacity = '0'
+ if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
+ }
+
+ return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave }
+}
diff --git a/client/src/components/Dawarich/DawarichAcceptDialog.tsx b/client/src/components/Dawarich/DawarichAcceptDialog.tsx
new file mode 100644
index 000000000..9faeca089
--- /dev/null
+++ b/client/src/components/Dawarich/DawarichAcceptDialog.tsx
@@ -0,0 +1,387 @@
+import React, { useMemo, useState } from 'react'
+import { Gauge, Hourglass, CircleDashed, Star } from 'lucide-react'
+import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
+import { DAWARICH_BUCKET_MATCH_MIN_MINUTES, DAWARICH_BUCKET_MATCH_RADIUS_M } from '@trek/shared'
+import { useTranslation } from '../../i18n'
+import Modal from '../shared/Modal'
+import MSheet from '../../mobile/components/MSheet'
+import { FormSheetFooter, FormSheetHeader } from '../../mobile/screens/trip/sheets/PlSheetChrome'
+import { useIsPhone } from '../../mobile/useIsPhone'
+import DawarichIcon from '../shared/DawarichIcon'
+import DawarichBadge from './DawarichBadge'
+import { CustomDatePicker } from '../shared/CustomDateTimePicker'
+import CustomTimePicker from '../shared/CustomTimePicker'
+import CustomSelect from '../shared/CustomSelect'
+
+export interface AcceptTargetOption {
+ id: number
+ label: string
+ /** Secondary half of the label — a day's date beside its number. */
+ badge?: string
+}
+
+export interface DawarichAcceptDialogProps {
+ suggestion: DawarichSuggestion | null
+ target: DawarichSuggestionTarget
+ /** Trips the user may write to, for the `place` target. */
+ trips?: AcceptTargetOption[]
+ /** Days of the chosen trip, resolved by the caller. */
+ daysForTrip?: (tripId: number) => AcceptTargetOption[]
+ /** Journeys the user may edit, for the `journal` target. */
+ journals?: AcceptTargetOption[]
+ busy?: boolean
+ onCancel: () => void
+ onConfirm: (body: DawarichAccept) => void
+}
+
+/**
+ * The review step: see what was recorded, correct it, then decide where it goes.
+ *
+ * The whole reason the integration is not a one-click import. A visit detector
+ * is right most of the time and confidently wrong the rest, and the difference
+ * between the two is a judgement only the traveller can make — so the values
+ * are prefilled from the recording and every one of them is editable before
+ * anything is written.
+ *
+ * What it does NOT do is offer to edit the recording. Dawarich owns that, and
+ * a correction made here would be lost the next time the detector ran.
+ *
+ * Three targets, because `DawarichSuggestionTarget` has three. The trip and
+ * journal rails open the first two; `bucket_list` is reached through MCP, where
+ * an assistant can accept a stay against a wish. The branch is kept for that and
+ * for the day an Atlas-side entry point wants it — the server has always been
+ * able to do it, and a dialog that could only handle two of three targets would
+ * be the thing that had to be fixed then.
+ */
+export default function DawarichAcceptDialog({
+ suggestion,
+ target,
+ trips = [],
+ daysForTrip,
+ journals = [],
+ busy = false,
+ onCancel,
+ onConfirm,
+}: DawarichAcceptDialogProps): React.ReactElement | null {
+ const { t, locale } = useTranslation()
+ const phone = useIsPhone()
+
+ const [name, setName] = useState('')
+ const [notes, setNotes] = useState('')
+ const [date, setDate] = useState('')
+ const [time, setTime] = useState('')
+ const [endTime, setEndTime] = useState('')
+ const [tripId, setTripId] = useState('')
+ const [dayId, setDayId] = useState('')
+ const [journalId, setJournalId] = useState('')
+ // Re-seeded whenever a different suggestion opens the dialog, so the fields
+ // always describe the row the user just pressed rather than the previous one.
+ const [seededFor, setSeededFor] = useState(null)
+
+ if (suggestion && seededFor !== suggestion.id) {
+ setSeededFor(suggestion.id)
+ setName(suggestion.name)
+ setNotes('')
+ setDate(suggestion.localDate)
+ setTime(clockOf(suggestion.startedAt))
+ setEndTime(clockOf(suggestion.endedAt))
+ setTripId(suggestion.tripId ?? (trips[0]?.id ?? ''))
+ setDayId('')
+ setJournalId(journals[0]?.id ?? '')
+ }
+
+ const days = useMemo(
+ () => (typeof tripId === 'number' && daysForTrip ? daysForTrip(tripId) : []),
+ [tripId, daysForTrip],
+ )
+
+ if (!suggestion) return null
+
+ const missingTarget =
+ (target === 'place' && typeof tripId !== 'number') ||
+ (target === 'journal' && typeof journalId !== 'number') ||
+ (target === 'bucket_list' && suggestion.matchedBucketListItemId === null)
+
+ const confirm = () => {
+ const body: DawarichAccept = { target }
+ if (target === 'place') {
+ if (typeof tripId === 'number') body.tripId = tripId
+ if (typeof dayId === 'number') body.dayId = dayId
+ }
+ if (target === 'journal' && typeof journalId === 'number') body.journalId = journalId
+ if (target === 'bucket_list' && suggestion.matchedBucketListItemId !== null) {
+ body.bucketListItemId = suggestion.matchedBucketListItemId
+ }
+ if (name.trim() && name.trim() !== suggestion.name) body.name = name.trim()
+ if (notes.trim()) body.notes = notes.trim()
+ if (date && date !== suggestion.localDate) body.date = date
+ if (time) body.time = time
+ if (endTime) body.endTime = endTime
+ onConfirm(body)
+ }
+
+ // The same fields in both shells — only the frame around them differs.
+ const fields = (
+
+ {/* What was recorded, before anything the user changed. Kept visible so a
+ corrected field can be compared against the source rather than
+ replacing it invisibly. */}
+ {/* What Dawarich recorded, as facts rather than as a sentence with
+ separators in it: a line for when, and a row of the same badges the
+ review list uses so the dialog and the list read as one thing. */}
+
+
+
+
+
+
+ {t('dawarich.accept.recorded', {
+ from: new Date(suggestion.startedAt).toLocaleString(locale),
+ to: new Date(suggestion.endedAt).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
+ })}
+
+
+
+
+ {t('dawarich.accept.duration', { minutes: suggestion.durationMinutes })}
+
+ {/* The source's own verdict, shown here rather than on every row of
+ the list: since Dawarich 1.12.0 "unconfirmed" is the normal state
+ of a visit, so it says nothing in a list and something here, where
+ somebody is deciding whether to trust this one. */}
+ {suggestion.sourceStatus === 'suggested' && (
+ {t('dawarich.sourceStatus.suggested')}
+ )}
+ {KNOWN_BANDS.includes(suggestion.confidenceBand ?? '') && (
+
+ {t('dawarich.confidence.' + suggestion.confidenceBand)}
+
+ )}
+
+
+
+ {/* Why this stay is being offered against a wish at all. The row that
+ led here shows the wish's name and nothing else, which leaves the
+ reader to guess what a star beside a café has to do with a museum —
+ it is the rule, and the rule is short enough to state. */}
+ {target === 'bucket_list' && suggestion.matchedBucketListName && (
+
+
+ {/* TREK's own pickers, not the browser's. A native `type="date"` paints
+ itself from the OS locale and a native `type="time"` ignores the 12h/24h
+ setting outright (#2067) — and neither follows the colour scheme. The
+ date column is the wide one because a localized date is long and a
+ clock never is. */}
+ {target !== 'bucket_list' && (
+
+
+
+ {t('dawarich.accept.date')}
+
+
+
+
+
+ {t('dawarich.accept.from')}
+
+
+
+
+
+ {t('dawarich.accept.to')}
+
+
+
+
+ )}
+
+ {/* Stacked and full width, in the order the choice is actually made: the
+ day is what somebody is deciding, the trip is usually already right.
+ Side by side, both pickers were half as wide as their longest option
+ and the menus opened narrower than the text in them. */}
+ {target === 'place' && (
+
+
+
+ {t('dawarich.accept.day')}
+
+ {/* Unplanned is a real answer, not a missing one: half the places on
+ a trip sit on no day, and forcing a choice here would invent one. */}
+ setDayId(next === NO_DAY || typeof next !== 'number' ? '' : next)}
+ options={[
+ { value: NO_DAY, label: t('dawarich.accept.noDay') },
+ // The date rides as the option's badge rather than inside its
+ // label: the day number is what people scan for, and the date
+ // is the confirmation they read second.
+ ...days.map(day => ({ value: day.id, label: day.label, badge: day.badge })),
+ ]}
+ style={{ width: '100%' }}
+ />
+
+ )
+
+ const title = t(`dawarich.accept.title.${target}`)
+
+ // The phone shell has its own sheet, its own rounding and its own footer pill,
+ // and every other form on it uses them. A desktop modal scaled down would be
+ // the one dialog in the app that came from somewhere else.
+ if (phone) {
+ return (
+
+
+ )
+
+ // Rendered or not, rather than `hidden`: the attribute loses to the display
+ // utility on the class list, so the button stayed on screen greyed out.
+ const confirmButton = !loaded || !anything ? null : (
+
+ )
+
+ const againButton = loaded ? (
+
+ ) : null
+
+ // The phone shell has its own sheet, its own rounding and its own pill
+ // buttons, and every other panel on it uses them.
+ if (phone) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* Rounded, like every other avatar-sized mark in TREK: the logo is a
+ square badge and a hard corner beside a rounded dialog reads as a
+ pasted-in image. */}
+
+
+
+
+ {t('dawarich.title')}
+
+ {t('dawarich.atlas.dialogSubtitle')}
+
+
+
+ }
+ footer={
+
+ }
+ >
+ {body}
+
+ )
+}
+
+/**
+ * One half of the dialog: the pitch before anything has been asked, the wait
+ * while it is being asked, and the answer.
+ *
+ * Both halves behave identically, which is the point — the two questions are
+ * different, the shape of answering them is not.
+ */
+function Section({
+ icon: Icon,
+ title,
+ description,
+ action,
+ busyLabel,
+ busy,
+ loaded,
+ onRun,
+ empty,
+ emptyLabel,
+ notes,
+ children,
+}: {
+ icon: React.ComponentType<{ className?: string }>
+ title: string
+ description: string
+ action: string
+ busyLabel: string
+ busy: boolean
+ loaded: boolean
+ onRun: () => void
+ empty: boolean
+ emptyLabel: string
+ notes: (string | null)[]
+ children: React.ReactNode
+}): React.ReactElement {
+ if (busy) {
+ return (
+
+
{busyLabel}
+ {[0, 1, 2].map(i => (
+
+
+
+
+
+
+
+ ))}
+
+ )
+ }
+
+ if (!loaded) {
+ return (
+
+
+
+
+
{title}
+
{description}
+
+
+ )
+ }
+
+ return (
+
+ {empty ? (
+
+ {emptyLabel}
+
+ ) : (
+
{children}
+ )}
+ {notes.filter(Boolean).length > 0 && (
+
+ {notes.filter(Boolean).map(note => (
+
+
+ {note}
+
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * A selectable answer: what it is on the left, why it is being offered
+ * underneath, and a tick that says whether it will be applied.
+ *
+ * The whole row is the control — a 16px checkbox is a hard target on a phone,
+ * and there is nothing else in the row to click.
+ */
+function Row({
+ name,
+ checked,
+ disabled = false,
+ onToggle,
+ leading,
+ children,
+}: {
+ name: string
+ checked: boolean
+ disabled?: boolean
+ onToggle: () => void
+ leading: React.ReactNode
+ children: React.ReactNode
+}): React.ReactElement {
+ return (
+
+ )
+}
+
+/**
+ * What to tell the reader when a question could not be asked.
+ *
+ * Being offline is not an error the server reported — nothing was sent — and
+ * "something went wrong" for a plane journey is the kind of message that sends
+ * somebody looking for a bug in their Dawarich instance.
+ */
+function failureText(err: unknown, t: (key: string) => string): string {
+ if (err instanceof DawarichOfflineError) return t('dawarich.error.offline')
+ return errorText(err) ?? t('dawarich.error.unknown')
+}
+
+/** The server's `{ error }` envelope, when there is one. */
+function errorText(err: unknown): string | null {
+ const data = (err as { response?: { data?: { error?: unknown } } })?.response?.data
+ return typeof data?.error === 'string' ? data.error : null
+}
diff --git a/client/src/components/Dawarich/DawarichAtlasSidePanel.tsx b/client/src/components/Dawarich/DawarichAtlasSidePanel.tsx
new file mode 100644
index 000000000..e27b2aca2
--- /dev/null
+++ b/client/src/components/Dawarich/DawarichAtlasSidePanel.tsx
@@ -0,0 +1,133 @@
+import React, { useState } from 'react'
+import { Globe2, Star } from 'lucide-react'
+import { useTranslation } from '../../i18n'
+import { useAddonStore } from '../../store/addonStore'
+import { Tooltip } from '../shared/Tooltip'
+import { useGlassGlare } from '../Atlas/useGlassGlare'
+import DawarichIcon from '../shared/DawarichIcon'
+import DawarichAtlasDialog from './DawarichAtlasDialog'
+
+type Question = 'wishes' | 'countries'
+
+/**
+ * Dawarich's own panel on the Atlas, beside the stats.
+ *
+ * The Atlas is a map with a single glass panel floating over it, and everything
+ * it offers lives in that panel. Dawarich answers two questions about the same
+ * map — which wishes were actually reached, and which countries the recordings
+ * say you were in — but they are *its* answers, not the Atlas's own numbers, and
+ * squeezed under the wishlist they read as fine print nobody asked for.
+ *
+ * So it gets a panel of its own, in the same glass and stretched to the same
+ * height, and the two questions are two tiles built like the stat blocks beside
+ * them: a mark, then a small capitalised label. The lists they produce open in a
+ * dialog — they are offers to read and confirm, and a bar over a world map is no
+ * place to read anything.
+ *
+ * Renders nothing when the addon is off, so an Atlas without Dawarich looks
+ * exactly as it did before.
+ */
+export default function DawarichAtlasSidePanel({
+ style,
+ dark,
+ onChanged,
+}: {
+ /** The glass the Atlas panels are made of, handed down so the two cannot drift. */
+ style: React.CSSProperties
+ dark: boolean
+ /** Called after something was written, so the Atlas can re-read itself. */
+ onChanged?: () => void
+}): React.ReactElement | null {
+ const { t } = useTranslation()
+ const addonEnabled = useAddonStore(state => state.isEnabled)
+ const [asked, setAsked] = useState(null)
+ const glare = useGlassGlare(dark)
+
+ if (!addonEnabled('dawarich')) return null
+
+ // theme-lint-disable — the panel floats on a blurred map: a solid surface
+ // token would punch an opaque hole in the glass, so the tiles are a tint of
+ // the same light the panel is made of.
+ const tile = dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.05)'
+ const tileHover = dark ? 'rgba(255,255,255,0.13)' : 'rgba(0,0,0,0.09)'
+
+ const question = (id: Question, Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>, label: string) => (
+
+ )
+
+ return (
+ <>
+
+ {/* Liquid glass glare, and the border glow that follows the cursor —
+ the same two layers the Atlas panel carries. */}
+
+
+ {/* The long explanation moved into the tooltip: on a panel this size it
+ was three lines of grey that said what the two tiles already say. */}
+
+
+
+
+
+
+ {t('dawarich.title')}
+
+
+
+
+
+
+ {/* Mounted on open so every visit starts from a clean question rather than
+ from a scan somebody ran an hour ago. */}
+ {asked !== null && (
+ setAsked(null)}
+ onChanged={onChanged}
+ />
+ )}
+ >
+ )
+}
diff --git a/client/src/components/Dawarich/DawarichBadge.tsx b/client/src/components/Dawarich/DawarichBadge.tsx
new file mode 100644
index 000000000..e92ec9e3d
--- /dev/null
+++ b/client/src/components/Dawarich/DawarichBadge.tsx
@@ -0,0 +1,71 @@
+import React from 'react'
+import { Tooltip } from '../shared/Tooltip'
+
+/**
+ * One badge shape for everything Dawarich shows.
+ *
+ * Fixed height and a single radius, so a row of them lines up however many there
+ * are; the tone is the only thing that varies, and it carries the meaning rather
+ * than decorating it. `quiet` is for a fact worth stating and not worth looking
+ * at twice.
+ *
+ * It lives in its own file because the review list, the accept dialog and the
+ * Atlas dialog all use it, and three copies of a pill is how two of them end up
+ * a pixel apart.
+ */
+export default function DawarichBadge({
+ icon: Icon,
+ tone = 'neutral',
+ size = 'xs',
+ title,
+ label,
+ children,
+}: {
+ icon?: React.ComponentType<{ className?: string }>
+ tone?: 'neutral' | 'quiet' | 'accent' | 'success' | 'warning'
+ /**
+ * `xs` matches the places rail, where the panel sits beside rows of that size;
+ * `sm` is for the dialogs, which are read at arm's length rather than scanned.
+ */
+ size?: 'xs' | 'sm'
+ /** Hover text. Defaults to `label` when the badge is icon-only. */
+ title?: string
+ /**
+ * The accessible name for an icon-only badge. Passing it instead of children
+ * is what makes a flag a round dot of colour without turning a screen reader
+ * into someone staring at an unlabelled icon.
+ */
+ label?: string
+ children?: React.ReactNode
+}): React.ReactElement {
+ const tones: Record = {
+ neutral: 'bg-surface-secondary text-content-secondary',
+ quiet: 'bg-surface-secondary text-content-faint',
+ accent: 'bg-accent-subtle text-accent-on',
+ success: 'bg-success-soft text-success',
+ warning: 'bg-warning-soft text-warning',
+ }
+ const metrics =
+ size === 'sm'
+ ? { style: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }, box: 'h-[20px]', icon: 'w-3 h-3' }
+ : { style: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' }, box: 'h-[18px]', icon: 'w-2.5 h-2.5' }
+ const iconOnly = children === undefined
+ const hint = title ?? label
+ const badge = (
+
+ {Icon && }
+ {!iconOnly && {children}}
+
+ )
+ // TREK's own tooltip rather than the browser's: the native one ignores the
+ // colour scheme, waits a second and a half, and cannot be read on a touch
+ // device at all. A badge with nothing to add gets none.
+ return hint ? {badge} : badge
+}
diff --git a/client/src/components/Dawarich/DawarichSuggestionsPanel.tsx b/client/src/components/Dawarich/DawarichSuggestionsPanel.tsx
new file mode 100644
index 000000000..8d9b0360f
--- /dev/null
+++ b/client/src/components/Dawarich/DawarichSuggestionsPanel.tsx
@@ -0,0 +1,523 @@
+import React, { useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, ChevronRight, MapPin, BookOpen, Check, X, RotateCcw, AlertTriangle, Clock, Hourglass, CalendarDays, Briefcase, Gauge, History, Unplug } from 'lucide-react'
+import type { DawarichAccept, DawarichSuggestion, DawarichSuggestionTarget } from '@trek/shared'
+import { useTranslation } from '../../i18n'
+import { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions'
+import { useTripStore } from '../../store/tripStore'
+import { useIsPhone } from '../../mobile/useIsPhone'
+import { relativeTime } from '../../utils/relativeTime'
+import { Tooltip } from '../shared/Tooltip'
+import DawarichIcon from '../shared/DawarichIcon'
+import Badge from './DawarichBadge'
+import DawarichAcceptDialog, { type AcceptTargetOption } from './DawarichAcceptDialog'
+import { formatDayHeading, formatDuration, groupByDay, timeRange } from './dawarichSuggestionModel'
+
+/**
+ * The panel sits inside the places rail, and it has to read at the same size as
+ * the rows below it — `text-body` (14px) and `text-caption` (12px) made every
+ * line of it a size larger than the trip's own places, which is the one thing a
+ * panel about somebody else's data must not be.
+ *
+ * These are the rail's own figures (PlacesSidebarRow uses exactly 13 / 11 / 10),
+ * kept as calc() against the per-tier multipliers so the appearance text-size
+ * control still reaches them — a bare number would not.
+ */
+const TYPE = {
+ name: { fontSize: 'calc(13px * var(--fs-scale-body, 1))' },
+ meta: { fontSize: 'calc(11px * var(--fs-scale-caption, 1))' },
+ micro: { fontSize: 'calc(10px * var(--fs-scale-caption, 1))' },
+} as const
+
+export interface DawarichSuggestionsPanelProps {
+ /** Only stays that fall inside this trip. */
+ tripId?: number
+ /** Trips the user may write a place into. */
+ trips?: AcceptTargetOption[]
+ daysForTrip?: (tripId: number) => AcceptTargetOption[]
+ /** Journeys the user may add an entry to. */
+ journals?: AcceptTargetOption[]
+ /** Called after something was written, so the host can refresh its own list. */
+ onAccepted?: (suggestion: DawarichSuggestion) => void
+ /** Start collapsed. The planner does; the journal does not. */
+ initiallyCollapsed?: boolean
+ /**
+ * Drop the panel's own card and header.
+ *
+ * For a sheet that already carries both — the phone's journey opens this list
+ * in one, and a second "From Dawarich" under the first is a stutter.
+ */
+ bare?: boolean
+}
+
+/**
+ * The stays Dawarich recorded, waiting to be reviewed.
+ *
+ * One component for both shells. The phone and the desktop place it
+ * differently, but the rows are the same rows, and a second copy of them is a
+ * second thing to keep in step — the same reasoning as `TripRouteOverviewPanel`
+ * next door, and the same reason SonarCloud's duplication budget exists.
+ *
+ * **Grouped by day, and coloured like the map.** A day of travel produces four
+ * or five stays; repeating the full date on each of them buries the one thing
+ * that tells them apart, which is the time. The day's dot carries the colour its
+ * line is drawn in on the trip map, so "the purple day" means the same thing in
+ * both places.
+ *
+ * The panel renders **nothing at all** when there is nothing to review. An
+ * integration that is connected and quiet should be invisible, not a permanent
+ * empty card explaining that it found nothing.
+ */
+export default function DawarichSuggestionsPanel({
+ tripId,
+ trips = [],
+ daysForTrip,
+ journals = [],
+ onAccepted,
+ initiallyCollapsed = false,
+ bare = false,
+}: DawarichSuggestionsPanelProps): React.ReactElement | null {
+ const { t, locale } = useTranslation()
+ const S = useDawarichSuggestions({ tripId })
+ // The phone shell has its own palette (`--m-*`): a card in the desktop surface
+ // tokens glares next to it. Same component, the shell's own colours.
+ const phone = useIsPhone()
+ const [collapsed, setCollapsed] = useState(initiallyCollapsed)
+ const [showHandled, setShowHandled] = useState(false)
+ const [pending, setPending] = useState<{ suggestion: DawarichSuggestion; target: DawarichSuggestionTarget } | null>(null)
+
+ // Deleting the place an acceptance created is how somebody says "not that
+ // one" after the fact: the server puts the stay back into review, and a
+ // shrinking place count is what tells us to go and ask. Only a shrink — a
+ // place being added is usually this panel's own doing.
+ const placeCount = useTripStore(state => (tripId === undefined ? 0 : state.places.length))
+ const lastPlaceCount = useRef(placeCount)
+ const reload = S.reload
+ useEffect(() => {
+ const shrank = placeCount < lastPlaceCount.current
+ lastPlaceCount.current = placeCount
+ if (shrank) reload()
+ }, [placeCount, reload])
+
+ const open = useMemo(() => S.suggestions.filter(item => item.state === 'new'), [S.suggestions])
+ const handled = useMemo(() => S.suggestions.filter(item => item.state !== 'new'), [S.suggestions])
+ const openDays = useMemo(() => groupByDay(open), [open])
+
+ // Nothing to review and nothing to report: stay out of the way entirely.
+ if (S.status === 'idle') return null
+ if (S.status === 'ready' && open.length === 0 && handled.length === 0) return null
+
+ const confirm = async (body: DawarichAccept) => {
+ if (!pending) return
+ const ok = await S.accept(pending.suggestion.id, body)
+ if (ok) {
+ onAccepted?.(pending.suggestion)
+ setPending(null)
+ }
+ }
+
+ const rowProps = {
+ phone,
+ // Offered only where there is somewhere to put it: the planner has no
+ // journey list, and a button that opens an empty picker is worse than none.
+ allowJournal: journals.length > 0,
+ allowPlace: trips.length > 0,
+ // The trip name is noise when every row is from the same trip.
+ showTrip: tripId === undefined,
+ }
+
+ return (
+
+ {bare ? (
+
+ )}
+
+ setPending(null)}
+ onConfirm={confirm}
+ />
+
+ )
+}
+
+/**
+ * One stay: a name, when it was, and how to act on it.
+ *
+ * Two lines and no chip soup. The meta line carries only what varies — how long,
+ * and the two things worth interrupting for: a wish this fulfils, and a
+ * detection the source itself was unsure about. The "detected, unconfirmed"
+ * label that used to sit on nearly every row is gone: since Dawarich 1.12.0
+ * unconfirmed is the normal state of a visit, so saying it everywhere said
+ * nothing. Reviewing it here *is* the confirmation.
+ *
+ * The actions fade in on hover on a pointer device and stay visible on touch,
+ * where there is no hover to reveal them.
+ */
+function SuggestionRow({
+ suggestion,
+ busy,
+ allowJournal = false,
+ allowPlace = false,
+ showTrip = false,
+ showDate = false,
+ phone = false,
+ onAccept,
+ onDismiss,
+ onRestore,
+}: {
+ suggestion: DawarichSuggestion
+ busy: boolean
+ allowJournal?: boolean
+ allowPlace?: boolean
+ showTrip?: boolean
+ showDate?: boolean
+ /** Rendered inside the phone shell, which has its own palette and its own reach. */
+ phone?: boolean
+ onAccept?: (target: DawarichSuggestionTarget) => void
+ onDismiss?: () => void
+ onRestore?: () => void
+}): React.ReactElement {
+ const { t, locale } = useTranslation()
+
+ return (
+
+ {/* A stay is a card with its actions clipped to the side — the shape an
+ expense already has in Costs. One layout in both shells; only the
+ palette differs, because the phone paints from `--m-*` and the desktop
+ from the semantic tokens, and neither set exists outside its own. */}
+
+ {/* The name gets the line, and the one badge that is about the name
+ itself rides with it: "Unsure" qualifies what this place is called,
+ not how long somebody stayed, and two lines below the name it read as
+ one more fact among the times. It never pushes the name out — the
+ name truncates, the flag does not. */}
+
+ {suggestion.name}
+ {/* The mark, never the word: the name is what the line is for, and
+ "Unsure" beside it is a caption competing with a title. The tooltip
+ and the accessible name still say it in full. */}
+ {suggestion.confidenceBand === 'low' && (
+
+ )}
+
+
+ {/* One badge row, one badge shape, colour carrying the meaning: neutral
+ for a fact, accent for a wish this fulfils, success for what was
+ already taken, amber for the two things worth interrupting for.
+ Nothing wraps — a row that reflows every time a name is long reads as
+ a mess, so it stays one line and the badges that do not fit are cut
+ off rather than pushed onto a second. */}
+ {/* Wraps, and only here: each badge is a fixed-height pill whose own text
+ never breaks, so a second line of them reads as a second line of
+ badges rather than as a paragraph coming apart. Cutting them off
+ instead would hide the wish a stay fulfils, which is the one thing in
+ the row worth the space. */}
+
+ {/* Ordered by what a reader needs first, because the rail is narrow and
+ the row does not wrap: how long, then what it would fulfil, then the
+ flags. The flags are icon-only — the word costs half the row and the
+ sentence under the badges says it properly anyway. */}
+ {showDate && (
+ {formatDayHeading(suggestion.localDate, locale)}
+ )}
+ {timeRange(suggestion.startedAt, suggestion.endedAt)}
+ {formatDuration(suggestion.durationMinutes, t)}
+
+ {suggestion.state === 'accepted' && (
+
+ {t('dawarich.suggestions.acceptedAs.' + (suggestion.target ?? 'journal'))}
+
+ )}
+ {suggestion.state === 'dismissed' && (
+ {t('dawarich.suggestions.dismissed')}
+ )}
+
+ {showTrip && suggestion.tripTitle && (
+ {suggestion.tripTitle}
+ )}
+
+ {suggestion.sourceMissing && (
+
+ )}
+ {!suggestion.sourceMissing && suggestion.sourceChanged && (
+
+ )}
+
+
+ {/* The long form of the two warnings, under the badges: the badge is the
+ flag, this is what it means, and somebody reading the panel for the
+ first time should not have to hover to find out. */}
+ {(suggestion.sourceChanged || suggestion.sourceMissing) && (
+
+
+ {/* Always on screen. Hiding them until hover made the only two things a row
+ is for invisible to anyone who had not discovered that hovering did
+ something — and on a touch screen there is no hover at all.
+
+ A column beside the text in the desktop rail, which is narrow and tall;
+ a row on a phone, where there is width to spare and a stack of small
+ squares reads as a scrollbar. Centred either way, against the name and
+ the time it belongs to. */}
+ {onAccept && (
+
+ )
+}
+
+/**
+ * Where a row keeps its actions: a pill clipped to the side of the card, the
+ * same in both shells, painted from whichever palette the shell has.
+ */
+const ACTIONS = (phone: boolean): string =>
+ 'flex flex-none flex-col gap-1 rounded-full border p-[5px] ' +
+ (phone ? 'border-[color:var(--m-gbr)] bg-[color:var(--m-glass)]' : 'border-edge bg-surface')
+
+/**
+ * One action on a row: a real target with a real accessible name.
+ *
+ * A square with a border rather than a bare glyph, because a bare glyph on a
+ * white rail reads as decoration; this reads as a button, which is what it is.
+ * The label is a tooltip and the accessible name — spelling it out beside the
+ * icon would cost the row the width its name needs.
+ *
+ * All of them look the same. Marking one as the primary action would be TREK
+ * deciding for the reader which of "put it on the map" and "write about it" they
+ * meant, and the whole review step exists because that is theirs to decide.
+ *
+ * Hover fills the square with the inverse surface — near-black in the light
+ * scheme, near-white in the dark one — so the button somebody is about to press
+ * is unmistakable without inventing a colour the theme does not have.
+ */
+function RowAction({
+ label,
+ icon,
+ disabled,
+ phone = false,
+ onClick,
+}: {
+ label: string
+ icon: React.ReactNode
+ disabled?: boolean
+ phone?: boolean
+ onClick: () => void
+}): React.ReactElement {
+ const button = (
+
+ )
+ // No tooltip on a phone: there is no hover to open it with, and the label is
+ // already the button's accessible name.
+ return phone ? button : {button}
+}
+
+/**
+ * Where the data came from and how fresh it is, in one line under the title.
+ *
+ * Relative rather than a stamp: "checked 2 minutes ago" answers the question
+ * somebody actually has, and "9/12/2026, 6:40:19 PM" makes them do the
+ * subtraction themselves — on two lines, seconds included.
+ */
+function statusLine(
+ state: ReturnType,
+ openCount: number,
+ t: (key: string, params?: Record) => string,
+ locale: string,
+): string {
+ if (state.status === 'loading') return t('dawarich.suggestions.loading')
+ if (state.status === 'disconnected') return t('dawarich.suggestions.notConnected')
+ if (state.status === 'offline') return t('dawarich.trail.offline')
+ if (state.status === 'unavailable') return t('dawarich.suggestions.unavailable')
+
+ // Just the freshness. The count sits in the badge beside this line, and
+ // gluing the two together with a separator said the same thing twice.
+ return state.lastSyncAt
+ ? t('dawarich.checkedAgo', { ago: relativeTime(Date.parse(state.lastSyncAt), locale) })
+ : t('dawarich.neverSynced')
+}
diff --git a/client/src/components/Dawarich/dawarichAtlasModel.test.ts b/client/src/components/Dawarich/dawarichAtlasModel.test.ts
new file mode 100644
index 000000000..9cef77144
--- /dev/null
+++ b/client/src/components/Dawarich/dawarichAtlasModel.test.ts
@@ -0,0 +1,113 @@
+// FE-DAWARICH-ATLASMODEL-001 to FE-DAWARICH-ATLASMODEL-010
+import { describe, it, expect } from 'vitest'
+import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
+import {
+ cityLine,
+ countryLabel,
+ countryWindow,
+ formatDistance,
+ newCountries,
+ orderedMatches,
+} from './dawarichAtlasModel'
+
+function match(over: Partial & { itemId: number }): DawarichBucketMatch {
+ return {
+ itemId: over.itemId,
+ name: over.name ?? `Wish ${over.itemId}`,
+ match: over.match === undefined ? { at: '2026-09-01T10:00:00Z', minutes: 90, distanceMeters: 40, points: 12 } : over.match,
+ alreadyVisited: over.alreadyVisited ?? false,
+ }
+}
+
+function country(over: Partial & { countryCode: string }): DawarichAtlasCountry {
+ return {
+ countryCode: over.countryCode,
+ sourceName: over.sourceName ?? over.countryCode,
+ cities: over.cities ?? [],
+ alreadyVisited: over.alreadyVisited ?? false,
+ }
+}
+
+const t = (key: string, params?: Record): string =>
+ `${key}:${JSON.stringify(params ?? {})}`
+
+describe('countryWindow', () => {
+ it('FE-DAWARICH-ATLASMODEL-001: asks for exactly the year before the given moment', () => {
+ const now = new Date('2026-09-12T18:00:00Z')
+ const { from, to } = countryWindow(now)
+ expect(to).toBe('2026-09-12T18:00:00.000Z')
+ expect(from).toBe('2025-09-12T18:00:00.000Z')
+ })
+})
+
+describe('orderedMatches', () => {
+ it('FE-DAWARICH-ATLASMODEL-002: drops wishes the recordings found nothing for', () => {
+ const ordered = orderedMatches([match({ itemId: 1, match: null }), match({ itemId: 2 })])
+ expect(ordered.map(m => m.itemId)).toEqual([2])
+ })
+
+ it('FE-DAWARICH-ATLASMODEL-003: open wishes lead, already ticked ones follow', () => {
+ const ordered = orderedMatches([
+ match({ itemId: 1, alreadyVisited: true }),
+ match({ itemId: 2 }),
+ ])
+ expect(ordered.map(m => m.itemId)).toEqual([2, 1])
+ })
+
+ it('FE-DAWARICH-ATLASMODEL-004: within a group the most recent visit comes first', () => {
+ const ordered = orderedMatches([
+ match({ itemId: 1, match: { at: '2026-01-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
+ match({ itemId: 2, match: { at: '2026-06-02T10:00:00Z', minutes: 30, distanceMeters: 10, points: 3 } }),
+ ])
+ expect(ordered.map(m => m.itemId)).toEqual([2, 1])
+ })
+
+ it('FE-DAWARICH-ATLASMODEL-005: leaves the caller\'s array untouched', () => {
+ const input = [match({ itemId: 1, alreadyVisited: true }), match({ itemId: 2 })]
+ orderedMatches(input)
+ expect(input.map(m => m.itemId)).toEqual([1, 2])
+ })
+})
+
+describe('newCountries', () => {
+ it('FE-DAWARICH-ATLASMODEL-006: only the ones a confirmation would actually change', () => {
+ const offered = newCountries([
+ country({ countryCode: 'DE', alreadyVisited: true }),
+ country({ countryCode: 'NL' }),
+ ])
+ expect(offered.map(c => c.countryCode)).toEqual(['NL'])
+ })
+})
+
+describe('countryLabel', () => {
+ it('FE-DAWARICH-ATLASMODEL-007: names the country in the reader\'s language', () => {
+ expect(countryLabel('NL', 'Netherlands', 'de')).toBe('Niederlande')
+ })
+
+ it('FE-DAWARICH-ATLASMODEL-008: falls back to Dawarich\'s own spelling for a code it cannot name', () => {
+ // Not an ISO region: Intl either throws or echoes it back, and neither is a
+ // name a reader would recognise.
+ expect(countryLabel('ZZZZ', 'Freedonia', 'en')).toBe('Freedonia')
+ })
+})
+
+describe('cityLine', () => {
+ it('FE-DAWARICH-ATLASMODEL-009: lists the first few cities and stops', () => {
+ const cities = ['Cologne', 'Bonn', 'Aachen', 'Trier'].map(name => ({
+ name,
+ minutes: 60,
+ lastSeenAt: '2026-09-01T10:00:00Z',
+ }))
+ expect(cityLine(cities)).toBe('Cologne, Bonn, Aachen')
+ expect(cityLine(cities, 1)).toBe('Cologne')
+ expect(cityLine([])).toBe('')
+ })
+})
+
+describe('formatDistance', () => {
+ it('FE-DAWARICH-ATLASMODEL-010: metres up to a kilometre, then kilometres with one decimal', () => {
+ expect(formatDistance(49.4, t)).toBe('dawarich.bucket.metersAway:{"meters":49}')
+ expect(formatDistance(999, t)).toBe('dawarich.bucket.metersAway:{"meters":999}')
+ expect(formatDistance(1400, t)).toBe('dawarich.bucket.kilometersAway:{"km":"1.4"}')
+ })
+})
diff --git a/client/src/components/Dawarich/dawarichAtlasModel.ts b/client/src/components/Dawarich/dawarichAtlasModel.ts
new file mode 100644
index 000000000..248659463
--- /dev/null
+++ b/client/src/components/Dawarich/dawarichAtlasModel.ts
@@ -0,0 +1,84 @@
+import type { DawarichAtlasCountry, DawarichBucketMatch } from '@trek/shared'
+
+/**
+ * Pure shaping for the Atlas dialog — no React, so the ordering, the naming and
+ * the window arithmetic can be tested without rendering anything.
+ */
+
+/**
+ * A year back from the given moment.
+ *
+ * Long enough to cover the trips someone would be filling in, short enough that
+ * a self-hosted instance is not asked to aggregate a decade of points for a card
+ * nobody was looking at. Takes `now` rather than reading the clock so the window
+ * is testable.
+ */
+export function countryWindow(now: Date): { from: string; to: string } {
+ const from = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000)
+ return { from: from.toISOString(), to: now.toISOString() }
+}
+
+/**
+ * The wishes worth showing, best first.
+ *
+ * Only entries the recordings actually found; the ones still open lead, because
+ * they are the ones the reader can act on, and within each group the most recent
+ * visit comes first — a wish reached last week is easier to confirm than one
+ * reached ten months ago.
+ */
+export function orderedMatches(matches: DawarichBucketMatch[]): DawarichBucketMatch[] {
+ return matches
+ .filter(match => match.match !== null)
+ .slice()
+ .sort((a, b) => {
+ if (a.alreadyVisited !== b.alreadyVisited) return a.alreadyVisited ? 1 : -1
+ return (b.match?.at ?? '') < (a.match?.at ?? '') ? -1 : 1
+ })
+}
+
+/** The countries a confirmation would actually change. */
+export function newCountries(countries: DawarichAtlasCountry[]): DawarichAtlasCountry[] {
+ return countries.filter(country => !country.alreadyVisited)
+}
+
+/**
+ * A country's name in the reader's language, falling back to Dawarich's own
+ * spelling.
+ *
+ * `Intl.DisplayNames` is missing on older WebViews and throws on a code it does
+ * not know, so both are caught — the Atlas page's own resolver does the same.
+ */
+export function countryLabel(code: string, sourceName: string, locale: string): string {
+ try {
+ return new Intl.DisplayNames([locale], { type: 'region' }).of(code) || sourceName
+ } catch {
+ return sourceName
+ }
+}
+
+/**
+ * The first few cities, as one line.
+ *
+ * Three is what fits beside a flag at the width the dialog has on a phone, and
+ * the count badge beside it already says how many there were in total.
+ */
+export function cityLine(cities: DawarichAtlasCountry['cities'], limit = 3): string {
+ return cities
+ .slice(0, limit)
+ .map(city => city.name)
+ .join(', ')
+}
+
+/**
+ * A distance a person would say out loud: metres up to a kilometre, then one
+ * decimal of a kilometre. `1400 m away` is a measurement; `1.4 km away` is how
+ * far it was.
+ */
+export function formatDistance(
+ meters: number,
+ t: (key: string, params?: Record) => string,
+): string {
+ return meters < 1000
+ ? t('dawarich.bucket.metersAway', { meters: Math.round(meters) })
+ : t('dawarich.bucket.kilometersAway', { km: (meters / 1000).toFixed(1) })
+}
diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.test.ts b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts
new file mode 100644
index 000000000..82a8eddab
--- /dev/null
+++ b/client/src/components/Dawarich/dawarichSuggestionModel.test.ts
@@ -0,0 +1,130 @@
+// FE-DAWARICH-SUGMODEL-001 to FE-DAWARICH-SUGMODEL-012
+import { describe, it, expect } from 'vitest'
+import type { DawarichSuggestion } from '@trek/shared'
+import {
+ clockOf,
+ formatDayHeading,
+ formatDayOption,
+ formatDuration,
+ groupByDay,
+ timeRange,
+} from './dawarichSuggestionModel'
+
+function stay(over: Partial & { id: number; localDate: string }): DawarichSuggestion {
+ return {
+ id: over.id,
+ sourceVisitId: String(over.id),
+ tripId: over.tripId ?? null,
+ tripTitle: over.tripTitle ?? null,
+ name: over.name ?? 'Cafe Reichard',
+ lat: over.lat ?? 50.94,
+ lng: over.lng ?? 6.96,
+ startedAt: over.startedAt ?? `${over.localDate}T10:15:00+02:00`,
+ endedAt: over.endedAt ?? `${over.localDate}T12:40:00+02:00`,
+ durationMinutes: over.durationMinutes ?? 145,
+ localDate: over.localDate,
+ sourceStatus: over.sourceStatus ?? 'suggested',
+ confidence: over.confidence ?? null,
+ confidenceBand: over.confidenceBand ?? null,
+ state: over.state ?? 'new',
+ target: over.target ?? null,
+ acceptedPlaceId: over.acceptedPlaceId ?? null,
+ acceptedJournalEntryId: over.acceptedJournalEntryId ?? null,
+ acceptedBucketListItemId: over.acceptedBucketListItemId ?? null,
+ sourceChanged: over.sourceChanged ?? false,
+ sourceMissing: over.sourceMissing ?? false,
+ matchedBucketListItemId: over.matchedBucketListItemId ?? null,
+ matchedBucketListName: over.matchedBucketListName ?? null,
+ countryCode: over.countryCode ?? null,
+ firstSeenAt: over.firstSeenAt ?? '2026-09-10T06:00:00Z',
+ lastSeenAt: over.lastSeenAt ?? '2026-09-10T06:00:00Z',
+ }
+}
+
+const t = (key: string, params?: Record): string =>
+ `${key}:${JSON.stringify(params ?? {})}`
+
+describe('groupByDay', () => {
+ it('FE-DAWARICH-SUGMODEL-001: newest day first, earliest stay first within a day', () => {
+ const days = groupByDay([
+ stay({ id: 1, localDate: '2026-09-10', startedAt: '2026-09-10T18:00:00+02:00' }),
+ stay({ id: 2, localDate: '2026-09-11' }),
+ stay({ id: 3, localDate: '2026-09-10', startedAt: '2026-09-10T09:00:00+02:00' }),
+ ])
+ expect(days.map(d => d.date)).toEqual(['2026-09-11', '2026-09-10'])
+ expect(days[1].stays.map(s => s.id)).toEqual([3, 1])
+ })
+
+ it('FE-DAWARICH-SUGMODEL-002: the colour counts from the oldest day, like the map does', () => {
+ const days = groupByDay([
+ stay({ id: 1, localDate: '2026-09-10' }),
+ stay({ id: 2, localDate: '2026-09-11' }),
+ ])
+ const oldest = days.find(d => d.date === '2026-09-10')!
+ const newest = days.find(d => d.date === '2026-09-11')!
+ expect(oldest.color).not.toBe(newest.color)
+ // Same input in a different order paints the same day the same colour.
+ const again = groupByDay([
+ stay({ id: 2, localDate: '2026-09-11' }),
+ stay({ id: 1, localDate: '2026-09-10' }),
+ ])
+ expect(again.find(d => d.date === '2026-09-10')!.color).toBe(oldest.color)
+ })
+
+ it('FE-DAWARICH-SUGMODEL-003: nothing in, nothing out', () => {
+ expect(groupByDay([])).toEqual([])
+ })
+})
+
+describe('clockOf / timeRange', () => {
+ it('FE-DAWARICH-SUGMODEL-004: reads the clock out of the timestamp\'s own offset', () => {
+ expect(clockOf('2026-09-10T10:15:00+02:00')).toBe('10:15')
+ })
+
+ it('FE-DAWARICH-SUGMODEL-005: an unreadable timestamp has no clock', () => {
+ expect(clockOf('yesterday')).toBe('')
+ expect(clockOf('')).toBe('')
+ })
+
+ it('FE-DAWARICH-SUGMODEL-006: a range when both ends read, the arrival alone when the end does not', () => {
+ expect(timeRange('2026-09-10T10:15:00+02:00', '2026-09-10T12:40:00+02:00')).toBe('10:15 – 12:40')
+ expect(timeRange('2026-09-10T10:15:00+02:00', 'nonsense')).toBe('10:15')
+ })
+
+ it('FE-DAWARICH-SUGMODEL-007: no arrival, no range', () => {
+ expect(timeRange('nonsense', '2026-09-10T12:40:00+02:00')).toBe('')
+ })
+})
+
+describe('formatDuration', () => {
+ it('FE-DAWARICH-SUGMODEL-008: minutes under an hour, hours above it, both when there is a remainder', () => {
+ expect(formatDuration(45, t)).toBe('dawarich.duration.minutes:{"minutes":45}')
+ expect(formatDuration(120, t)).toBe('dawarich.duration.hours:{"hours":2}')
+ expect(formatDuration(145, t)).toBe('dawarich.duration.hoursMinutes:{"hours":2,"minutes":25}')
+ })
+})
+
+describe('formatDayHeading', () => {
+ it('FE-DAWARICH-SUGMODEL-009: reads as the local date, not as yesterday', () => {
+ // Built from the date parts rather than from a Date at midnight UTC, which
+ // is the difference between "Thu, 10 Sept" and "Wed, 9 Sept" west of UTC.
+ expect(formatDayHeading('2026-09-10', 'en-GB')).toContain('10')
+ expect(formatDayHeading('2026-09-10', 'en-GB')).toMatch(/Thu/)
+ })
+
+ it('FE-DAWARICH-SUGMODEL-010: an unparseable date is shown as it came', () => {
+ expect(formatDayHeading('not-a-date', 'en-GB')).toBe('not-a-date')
+ })
+})
+
+describe('formatDayOption', () => {
+ it('FE-DAWARICH-SUGMODEL-011: the day number is the label, the date rides along as a badge', () => {
+ const option = formatDayOption(3, '2026-09-10', 'en-GB', t)
+ expect(option.label).toBe('planner.dayN:{"n":3}')
+ expect(option.badge).toMatch(/10/)
+ })
+
+ it('FE-DAWARICH-SUGMODEL-012: a day without a date gets no badge rather than an empty one', () => {
+ expect(formatDayOption(1, null, 'en-GB', t)).toEqual({ label: 'planner.dayN:{"n":1}', badge: undefined })
+ })
+})
diff --git a/client/src/components/Dawarich/dawarichSuggestionModel.ts b/client/src/components/Dawarich/dawarichSuggestionModel.ts
new file mode 100644
index 000000000..eb53eec7f
--- /dev/null
+++ b/client/src/components/Dawarich/dawarichSuggestionModel.ts
@@ -0,0 +1,117 @@
+import type { DawarichSuggestion } from '@trek/shared'
+import { dayColor } from '../Map/dawarichTrail'
+
+/**
+ * Pure shaping for the suggestions panel — no React, so the grouping and the
+ * formatting can be tested without rendering anything.
+ */
+
+export interface SuggestionDay {
+ /** `YYYY-MM-DD`, the stay's own local date. */
+ date: string
+ /** The colour this day is drawn in on the map, so the panel and the line agree. */
+ color: string
+ stays: DawarichSuggestion[]
+}
+
+/**
+ * Group stays by the local day they happened on, newest day first, and give each
+ * day the colour the map draws it in.
+ *
+ * The grouping is what turns a flat list into something readable: a day of
+ * travel produces four or five stays, and repeating the full date on every row
+ * buries the one thing that distinguishes them — the time.
+ *
+ * The colour index counts days from the OLDEST, matching how the track overlay
+ * numbers them, so "the purple line" and "the purple day" are the same day.
+ */
+export function groupByDay(suggestions: DawarichSuggestion[]): SuggestionDay[] {
+ const byDate = new Map()
+ for (const stay of suggestions) {
+ const bucket = byDate.get(stay.localDate)
+ if (bucket) bucket.push(stay)
+ else byDate.set(stay.localDate, [stay])
+ }
+
+ const oldestFirst = [...byDate.keys()].sort()
+ return oldestFirst
+ .map((date, index) => ({
+ date,
+ color: dayColor(index),
+ // Within a day, earliest first: that is the order they were lived in.
+ stays: byDate.get(date)!.slice().sort((a, b) => (a.startedAt < b.startedAt ? -1 : 1)),
+ }))
+ .reverse()
+}
+
+/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
+export function clockOf(iso: string): string {
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : ''
+}
+
+/**
+ * `10:15 – 12:40`, or just the arrival when the end is unreadable.
+ *
+ * An en dash with hair spaces rather than a hyphen: this is a range, and it sits
+ * next to times often enough that the difference is visible.
+ */
+export function timeRange(startedAt: string, endedAt: string): string {
+ const from = clockOf(startedAt)
+ const to = clockOf(endedAt)
+ if (!from) return ''
+ return to ? `${from} – ${to}` : from
+}
+
+/**
+ * A duration a person would say out loud.
+ *
+ * "285 min" is a number from a database; "4 h 45 min" is how long somebody was
+ * somewhere. The two label keys carry the units so a locale can put them where
+ * it wants them.
+ */
+export function formatDuration(
+ minutes: number,
+ t: (key: string, params?: Record) => string,
+): string {
+ if (minutes < 60) return t('dawarich.duration.minutes', { minutes })
+ const hours = Math.floor(minutes / 60)
+ const rest = minutes % 60
+ return rest === 0
+ ? t('dawarich.duration.hours', { hours })
+ : t('dawarich.duration.hoursMinutes', { hours, minutes: rest })
+}
+
+/**
+ * A stay's day header, in the reader's language: `Thu, 10 Sept`.
+ *
+ * Built from the date string rather than from a Date at midnight UTC, so a
+ * reader east or west of UTC does not see yesterday.
+ */
+export function formatDayHeading(date: string, locale: string): string {
+ const [year, month, day] = date.split('-').map(Number)
+ if (!year || !month || !day) return date
+ return new Date(year, month - 1, day).toLocaleDateString(locale, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ })
+}
+
+/**
+ * How a trip day reads in the accept dialog's picker.
+ *
+ * `1. 2026-09-09` is a row out of a database. What a person recognises is the
+ * number the planner gives the day and the weekday it fell on, which is exactly
+ * what the day rail shows them — so the picker says the same thing.
+ */
+export function formatDayOption(
+ dayNumber: number,
+ date: string | null | undefined,
+ locale: string,
+ t: (key: string, params?: Record) => string,
+): { label: string; badge?: string } {
+ return {
+ label: t('planner.dayN', { n: dayNumber }),
+ badge: date ? formatDayHeading(date, locale) : undefined,
+ }
+}
diff --git a/client/src/components/Dawarich/dawarichTripRefresh.ts b/client/src/components/Dawarich/dawarichTripRefresh.ts
new file mode 100644
index 000000000..81d1a18b1
--- /dev/null
+++ b/client/src/components/Dawarich/dawarichTripRefresh.ts
@@ -0,0 +1,18 @@
+import { useTripStore } from '../../store/tripStore'
+
+/**
+ * Pull the trip back in after a stay was accepted into it.
+ *
+ * The acceptance is written server-side, so the planner knows nothing about the
+ * place it produced until somebody reloads the page — which is exactly what a
+ * person should never have to do after pressing a button. Both trip shells call
+ * this, which is also why it is a plain function rather than a hook: one line at
+ * each call site keeps the desktop and phone copies from drifting.
+ *
+ * Places and days both, because a stay accepted onto a day creates an assignment
+ * as well, and the day rail would otherwise stay one place short.
+ */
+export async function refreshTripAfterAccept(tripId: number | string): Promise {
+ const store = useTripStore.getState()
+ await Promise.all([store.refreshPlaces(tripId), store.refreshDays(tripId)])
+}
diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
index a7d3e4668..27f4108d5 100644
--- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
+++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx
@@ -606,13 +606,17 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
-
+ {/* The date needs the room, not the clock: a long localized date
+ ("12. Sept. 2026") wrapped onto a second line while the time field
+ sat half empty beside it. The row is split in the date's favour and
+ the location, which is a free-text search, gives some back. */}
+
{/* Time sat in state and went to the server, it just had no input here — so a
draft's auto-stamped clock time showed up in the timeline, the map, the PDF
and the public share, and the desktop had no way to correct it (#1614). */}
- {/* 136px, not 104: the custom picker adds a clock button and, in 12h,
- shows "2:30 PM" where the native input showed a fixed-width HH:MM. */}
-
+ {/* 112px: enough for the clock button plus "2:30 PM" in 12h, and no
+ more — the date column is what needed the space back. */}
+
diff --git a/client/src/components/Map/DawarichTrailLayer.tsx b/client/src/components/Map/DawarichTrailLayer.tsx
new file mode 100644
index 000000000..2a4aba884
--- /dev/null
+++ b/client/src/components/Map/DawarichTrailLayer.tsx
@@ -0,0 +1,78 @@
+import { useMemo } from 'react'
+import { Polyline } from 'react-leaflet'
+import type { DawarichTrack } from '@trek/shared'
+import {
+ DAWARICH_TRAIL_CASING,
+ trailSegments,
+ type DawarichTrailSegment,
+} from './dawarichTrail'
+
+/**
+ * The recorded route on the Leaflet renderer.
+ *
+ * Three lines per segment, the same construction the GPX tracks next door use:
+ * a white casing so the line survives a satellite tile, the coloured line
+ * itself, and nothing on top — this overlay is not clickable. That last part is
+ * deliberate: a fat invisible hit-line would sit over the places and the
+ * planned route, and swallow the clicks that select them, in exchange for a
+ * popup nobody asked for.
+ *
+ * Dashed rather than solid. The planned route is already a solid line in the
+ * user's accent, and the whole point of this layer is to be visibly the other
+ * thing — what actually happened, next to what was planned. Telling them apart
+ * has to survive being printed, screenshotted and looked at by someone who
+ * cannot distinguish the two hues.
+ */
+export default function DawarichTrailLayer({
+ track,
+ selectedDate,
+ casingPane,
+}: {
+ track: DawarichTrack | null
+ /** When set, only that local day is drawn. */
+ selectedDate?: string | null
+ /** The `trek-track-casing` pane, when the renderer supports panes. */
+ casingPane?: string
+}) {
+ const segments = useMemo(() => trailSegments(track, selectedDate), [track, selectedDate])
+ if (segments.length === 0) return null
+
+ return (
+ <>
+ {segments.map((segment: DawarichTrailSegment) => (
+
+ ))}
+ {/* pathOptions rather than bare props: react-leaflet only calls setStyle
+ when the pathOptions reference changes, so a bare `color` would stick
+ at its mount-time value and a day recolour would never arrive. */}
+ {segments.map((segment: DawarichTrailSegment) => (
+
+ ))}
+ >
+ )
+}
diff --git a/client/src/components/Map/DawarichTrailPill.tsx b/client/src/components/Map/DawarichTrailPill.tsx
new file mode 100644
index 000000000..471f0020d
--- /dev/null
+++ b/client/src/components/Map/DawarichTrailPill.tsx
@@ -0,0 +1,93 @@
+import { useTranslation } from '../../i18n'
+import { Tooltip } from '../shared/Tooltip'
+import DawarichIcon from '../shared/DawarichIcon'
+import { MAP_CONTROL_SHADOW } from './mapControlShadow'
+import type { DawarichTrailStatus } from './useDawarichTrail'
+
+/**
+ * Toggle for the recorded-route overlay (#2279), in the same frosted shell as
+ * the compass, the layer switcher and the trip-route overview so the map's
+ * controls stay one family.
+ *
+ * The status rides on the button rather than in a banner: the states that
+ * matter — loading, nothing recorded for these dates, instance unreachable,
+ * device offline — are all answers to "why is there no line", and the place
+ * someone looks for that answer is the control they just pressed.
+ */
+export function DawarichTrailPill({
+ active,
+ status,
+ onToggle,
+}: {
+ active: boolean
+ status: DawarichTrailStatus
+ onToggle: () => void
+}) {
+ const { t } = useTranslation()
+
+ const label = !active
+ ? t('dawarich.trail.show')
+ : status === 'loading'
+ ? t('dawarich.trail.loading')
+ : status === 'empty'
+ ? t('dawarich.trail.empty')
+ : status === 'offline'
+ ? t('dawarich.trail.offline')
+ : status === 'unavailable'
+ ? t('dawarich.trail.unavailable')
+ : t('dawarich.trail.hide')
+
+ // A problem worth seeing without reading the tooltip: the layer is on and
+ // there is nothing to show.
+ const muted = active && (status === 'empty' || status === 'offline' || status === 'unavailable')
+
+ return (
+
+ {/* TREK's own tooltip: the native one is unreadable in dark mode and never
+ appears on a touch device, and this control's whole job is to explain
+ why there is no line. */}
+
+
+
+
+ )
+}
diff --git a/client/src/components/Map/MapLayerSwitcher.tsx b/client/src/components/Map/MapLayerSwitcher.tsx
index 2c7db30b8..f9700d8dd 100644
--- a/client/src/components/Map/MapLayerSwitcher.tsx
+++ b/client/src/components/Map/MapLayerSwitcher.tsx
@@ -1,5 +1,6 @@
import { Map as MapIcon, Satellite } from 'lucide-react'
import { useTranslation } from '../../i18n'
+import { Tooltip } from '../shared/Tooltip'
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
export type BaseLayer = 'default' | 'satellite'
@@ -21,24 +22,29 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: MAP_CONTROL_SHADOW,
}}>
-
+ {/* Same tooltip as its neighbours on the map, for the same reasons — see
+ TripRouteOverviewPill. To the right, though: this one sits at the
+ bottom left of the map, where a tooltip to the left would land on the
+ day sidebar and one below would land off the screen. */}
+
+
+
)
}
diff --git a/client/src/components/Map/MapView.tsx b/client/src/components/Map/MapView.tsx
index 276d2c049..9fede926c 100644
--- a/client/src/components/Map/MapView.tsx
+++ b/client/src/components/Map/MapView.tsx
@@ -53,6 +53,7 @@ import { NightPauseDrag } from './NightPauseDrag'
import type { DayBoundaryControls } from './dayBoundaryDrag'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { resolveTrackColor, hasManualTrackColor } from './trackColors'
+import DawarichTrailLayer from './DawarichTrailLayer'
import { OFM_POSITRON, DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM, MAP_MAX_ZOOM, SATELLITE_TILE_URL, SATELLITE_TILE_MAXZOOM, AMAP_SATELLITE, attributionForTile } from '../../constants/mapDefaults'
import { crsForBasemap } from './gcj02Crs'
import { isGcj02Basemap, resolveBasemap } from '../../utils/tileUrl'
@@ -669,6 +670,8 @@ export const MapView = memo(function MapView({
routeVias = [],
dayBoundaryControls,
hazards,
+ dawarichTrack = null,
+ dawarichSelectedDate = null,
accessLines = [],
onPoiDropOnRoute,
onRouteClick,
@@ -1179,6 +1182,15 @@ export const MapView = memo(function MapView({
/>
{hazards?.length > 0 && }
+
+ {/* The route as it was actually recorded (#2279). Drawn in the casing
+ pane's sibling order so it sits under the planned route rather than
+ over it — the plan is what the user is editing. */}
+ {poiMarkers}
{/* Charging stops / rest areas a plugin route places on the drawn day route.
Host-vetted data (server-normalized), rendered as plain tone dots. */}
diff --git a/client/src/components/Map/MapViewAuto.tsx b/client/src/components/Map/MapViewAuto.tsx
index 572c4012d..09ae481d3 100644
--- a/client/src/components/Map/MapViewAuto.tsx
+++ b/client/src/components/Map/MapViewAuto.tsx
@@ -20,6 +20,9 @@ import { useTranslation } from '../../i18n/TranslationContext'
export function MapViewAuto(props: any) {
const { t } = useTranslation()
const hazards = useRoadtripHazards(props.tripId, !!props.clusterLoosely)
+ // `dawarichTrack` arrives as a prop rather than being fetched here: the pill
+ // that switches it on lives at page level and needs the load status, so the
+ // fetch sits in useTripPlanner and both shells read the same one.
const mapProps = { ...props, hazards: hazards.feed?.hazards }
const provider = useSettingsStore(s => s.settings.map_provider)
const token = useSettingsStore(s => s.settings.mapbox_access_token)
diff --git a/client/src/components/Map/MapViewGL.test.tsx b/client/src/components/Map/MapViewGL.test.tsx
index 418cf011a..059f295b9 100644
--- a/client/src/components/Map/MapViewGL.test.tsx
+++ b/client/src/components/Map/MapViewGL.test.tsx
@@ -34,6 +34,11 @@ const glMap = vi.hoisted(() => ({
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
+ // Every overlay that adds a layer takes it away again on cleanup; a real
+ // MapLibre/Mapbox map has both halves, and a double with only the adding half
+ // fails the teardown rather than the behaviour under test.
+ removeLayer: vi.fn(),
+ removeSource: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
diff --git a/client/src/components/Map/MapViewGL.tsx b/client/src/components/Map/MapViewGL.tsx
index 06d4d7959..afd0a097a 100644
--- a/client/src/components/Map/MapViewGL.tsx
+++ b/client/src/components/Map/MapViewGL.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
-import type { RoadtripVia } from '@trek/shared'
+import type { DawarichTrack, RoadtripVia } from '@trek/shared'
import { useStableVias } from './viaMarkerState'
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
@@ -30,6 +30,7 @@ import { nightPauseMarker, NIGHT_PAUSE_MIN_ZOOM } from './nightPauseMarker'
import { clusterPois, poiClusterMarkup, poiClusterList, POI_CLUSTER_DETAIL_ZOOM } from './poiClusters'
import type { RoadtripHazard } from '@trek/shared'
import { useHazardLayerGL } from './useHazardLayerGL'
+import { useDawarichTrailGL } from './useDawarichTrailGL'
import { bindDayBoundaryDrag, type DayBoundaryControls } from './dayBoundaryDrag'
import NightPauseTooltip from './NightPauseTooltip'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
@@ -190,6 +191,10 @@ interface Props {
*/
clusterLoosely?: boolean
hazards?: RoadtripHazard[]
+ /** The route recorded in Dawarich, already fetched by MapViewAuto (#2279). */
+ dawarichTrack?: DawarichTrack | null
+ /** Draw only this local day of the recording. */
+ dawarichSelectedDate?: string | null
/** Via points to draw as draggable handles, keyed by day (#1797). */
roadtripVias?: Record
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
@@ -635,6 +640,8 @@ export function MapViewGL({
focusPoints,
clusterLoosely = false,
hazards,
+ dawarichTrack = null,
+ dawarichSelectedDate = null,
dayOrderMap = NO_DAY_ORDER,
leftWidth = 0,
rightWidth = 0,
@@ -712,6 +719,8 @@ export function MapViewGL({
const mapRef = useRef(null)
const hazardPopupFactory = useCallback(() => new gl.Popup({ className: 'map-tooltip trek-hazard-popup', maxWidth: '320px' }), [gl])
useHazardLayerGL(mapRef.current, mapReady, hazards, hazardPopupFactory)
+ // Beneath the planned route's casing, the GL twin of the Leaflet pane order.
+ useDawarichTrailGL(mapRef.current, mapReady, dawarichTrack, dawarichSelectedDate, 'trip-route-casing')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const markersRef = useRef
)
}
diff --git a/client/src/components/Map/dawarichTrail.ts b/client/src/components/Map/dawarichTrail.ts
new file mode 100644
index 000000000..5d56408d3
--- /dev/null
+++ b/client/src/components/Map/dawarichTrail.ts
@@ -0,0 +1,145 @@
+import type { DawarichTrack, DawarichTrackDay } from '@trek/shared'
+
+/**
+ * Everything both map renderers need to agree on for the recorded-route
+ * overlay, in one module.
+ *
+ * TREK draws no line twice the same way — Leaflet takes `` elements
+ * and GL takes a GeoJSON source — so the *shape* cannot be shared. The colour,
+ * the day ordering and the identity of a segment can, and they are exactly what
+ * would drift between the two renderers if each carried its own copy.
+ */
+
+/**
+ * The palette for the recorded route.
+ *
+ * Deliberately not derived from `var(--accent)`: the planned route already uses
+ * the accent, and the whole point of this overlay is that it reads as *the
+ * other thing* — what happened, next to what was planned. These are the same
+ * hues Dawarich itself uses for its track modes, which makes the two products
+ * agree about a line the user has seen in both.
+ *
+ * theme-lint-disable — map paint: Leaflet path options and GL paint
+ * expressions cannot read a CSS variable, and the map is the one surface where
+ * a fixed palette is the documented exception (client/src/theme/README.md).
+ */
+export const DAWARICH_TRAIL_COLOR = '#2563EB' // theme-lint-disable — map paint
+/** The white casing under the line, so it stays visible over a satellite tile. */
+export const DAWARICH_TRAIL_CASING = '#FFFFFF' // theme-lint-disable — map paint
+
+/**
+ * Per-day hues, cycled.
+ *
+ * A multi-day trip drawn in one colour is a scribble; per-day colours make "we
+ * were here on Tuesday" readable at a glance. Six is enough to tell adjacent
+ * days apart and few enough that the map does not turn into a rainbow.
+ *
+ * theme-lint-disable — map paint, same reason as above.
+ */
+export const DAWARICH_DAY_COLORS = [
+ '#2563EB', // theme-lint-disable — map paint
+ '#7C3AED', // theme-lint-disable — map paint
+ '#0891B2', // theme-lint-disable — map paint
+ '#DB2777', // theme-lint-disable — map paint
+ '#EA580C', // theme-lint-disable — map paint
+ '#059669', // theme-lint-disable — map paint
+] as const
+
+export function dayColor(index: number): string {
+ return DAWARICH_DAY_COLORS[index % DAWARICH_DAY_COLORS.length]!
+}
+
+/** One drawable piece of the overlay, flattened out of the day/segment nesting. */
+export interface DawarichTrailSegment {
+ /** Stable across refetches, so a renderer can key on it instead of on array order. */
+ id: string
+ date: string
+ color: string
+ /** `[lat, lng]`, TREK's internal order. GeoJSON flips it at the boundary. */
+ points: Array<[number, number]>
+ mode: string | null
+ startedAt: string
+ endedAt: string
+ distanceMeters: number | null
+}
+
+/**
+ * Flatten a fetched track into drawable segments, optionally limited to one day.
+ *
+ * The day filter is what the timeline scrubber drives: selecting a day on the
+ * planner should dim the rest of the trip rather than reload it, so the filter
+ * happens here over data that is already in memory.
+ */
+export function trailSegments(
+ track: DawarichTrack | null,
+ onlyDate?: string | null,
+): DawarichTrailSegment[] {
+ if (!track) return []
+ const days: DawarichTrackDay[] = onlyDate
+ ? track.days.filter(day => day.date === onlyDate)
+ : track.days
+
+ const out: DawarichTrailSegment[] = []
+ for (const day of days) {
+ // The colour index comes from the day's position in the WHOLE track, not in
+ // the filtered list — otherwise picking a day would repaint it.
+ const index = track.days.findIndex(candidate => candidate.date === day.date)
+ day.segments.forEach((segment, i) => {
+ // Zod's tuple infers as "maybe two numbers, maybe more", so the pairs are
+ // narrowed at runtime rather than cast. In practice the schema has already
+ // guaranteed both are numbers; this is what makes the type honest without
+ // an `as` at the boundary.
+ const points = segment.points.flatMap((pair): Array<[number, number]> =>
+ typeof pair[0] === 'number' && typeof pair[1] === 'number' ? [[pair[0], pair[1]]] : [],
+ )
+ if (points.length < 2) return
+ out.push({
+ id: `${day.date}-${i}`,
+ date: day.date,
+ color: dayColor(index < 0 ? 0 : index),
+ points,
+ mode: segment.mode,
+ startedAt: segment.startedAt,
+ endedAt: segment.endedAt,
+ distanceMeters: segment.distanceMeters,
+ })
+ })
+ }
+ return out
+}
+
+/** The overlay as a GeoJSON FeatureCollection, for the GL renderer. */
+export function trailGeoJson(segments: DawarichTrailSegment[]): {
+ type: 'FeatureCollection'
+ features: Array<{
+ type: 'Feature'
+ properties: { id: string; color: string; date: string; mode: string | null }
+ geometry: { type: 'LineString'; coordinates: Array<[number, number]> }
+ }>
+} {
+ return {
+ type: 'FeatureCollection',
+ features: segments.map(segment => ({
+ type: 'Feature' as const,
+ properties: {
+ id: segment.id,
+ color: segment.color,
+ date: segment.date,
+ mode: segment.mode,
+ },
+ geometry: {
+ type: 'LineString' as const,
+ // Internal order is [lat, lng] everywhere in TREK; GeoJSON wants the
+ // other one, and this is the single place the overlay flips it.
+ coordinates: segment.points.map((point): [number, number] => [point[1], point[0]]),
+ },
+ })),
+ }
+}
+
+/** Total kilometres the overlay covers, for the panel that reports it. */
+export function trailDistanceKm(segments: DawarichTrailSegment[]): number | null {
+ const known = segments.filter(segment => segment.distanceMeters !== null)
+ if (known.length === 0) return null
+ return known.reduce((sum, segment) => sum + (segment.distanceMeters ?? 0), 0) / 1000
+}
diff --git a/client/src/components/Map/useDawarichTrail.ts b/client/src/components/Map/useDawarichTrail.ts
new file mode 100644
index 000000000..6aed9f11b
--- /dev/null
+++ b/client/src/components/Map/useDawarichTrail.ts
@@ -0,0 +1,104 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import type { DawarichTrack } from '@trek/shared'
+import { dawarichRepo } from '../../repo/dawarichRepo'
+import { useAddonStore } from '../../store/addonStore'
+import { isEffectivelyOffline, onNetworkModeChange } from '../../sync/networkMode'
+
+export type DawarichTrailStatus = 'idle' | 'loading' | 'ready' | 'empty' | 'unavailable' | 'offline'
+
+export interface DawarichTrailState {
+ track: DawarichTrack | null
+ status: DawarichTrailStatus
+ /** ISO-8601 of the last fetch that actually returned something. */
+ fetchedAt: string | null
+ reload: () => void
+}
+
+/**
+ * The recorded route for a trip, fetched while the overlay is on.
+ *
+ * Called **once** in `MapViewAuto` and mirrored into both renderers as a prop —
+ * the same shape `useRoadtripHazards` uses, and for the same reason: two
+ * renderers each fetching would double the load on somebody's self-hosted
+ * instance and could draw two different answers.
+ *
+ * Refreshes on a timer while the layer is on, so a trip in progress catches up
+ * without anyone reloading the page. Two minutes rather than seconds: this
+ * reaches across the network to a server the user runs, and the server caches
+ * for a minute anyway, so a tighter loop would buy nothing and cost them.
+ */
+const REFRESH_MS = 120_000
+
+export function useDawarichTrail(
+ tripId: number | undefined,
+ enabled: boolean,
+ range?: { from?: string; to?: string },
+): DawarichTrailState {
+ const addonEnabled = useAddonStore(s => s.isEnabled)
+ const active = enabled && !!tripId && addonEnabled('dawarich')
+
+ const [track, setTrack] = useState(null)
+ const [status, setStatus] = useState('idle')
+ const [fetchedAt, setFetchedAt] = useState(null)
+ const [reloadToken, setReloadToken] = useState(0)
+
+ // Kept in a ref so changing the window does not re-create the effect's
+ // identity on every render of the parent — the object literal would.
+ const rangeRef = useRef(range)
+ rangeRef.current = range
+ const rangeKey = `${range?.from ?? ''}|${range?.to ?? ''}`
+
+ const reload = useCallback(() => setReloadToken(token => token + 1), [])
+
+ useEffect(() => {
+ if (!active || !tripId) {
+ setTrack(null)
+ setStatus('idle')
+ return
+ }
+
+ let controller: AbortController | null = null
+ let cancelled = false
+
+ const load = () => {
+ controller?.abort()
+ controller = new AbortController()
+ const signal = controller.signal
+
+ if (isEffectivelyOffline()) {
+ setStatus('offline')
+ return
+ }
+ // The previous line stays on screen while the next one loads: blanking it
+ // makes a refresh look like the recording disappeared.
+ setStatus(current => (current === 'ready' ? 'ready' : 'loading'))
+
+ dawarichRepo
+ .tripTrack(tripId, rangeRef.current, signal)
+ .then(result => {
+ if (cancelled || signal.aborted) return
+ setTrack(result)
+ setFetchedAt(result.fetchedAt)
+ setStatus(result.days.length > 0 ? 'ready' : 'empty')
+ })
+ .catch(() => {
+ if (cancelled || signal.aborted) return
+ // Keep whatever was drawn. A line from two minutes ago is more useful
+ // than an empty map, and the status is what says it may be stale.
+ setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable')
+ })
+ }
+
+ load()
+ const timer = window.setInterval(load, REFRESH_MS)
+ const unsubscribe = onNetworkModeChange(load)
+ return () => {
+ cancelled = true
+ controller?.abort()
+ window.clearInterval(timer)
+ unsubscribe()
+ }
+ }, [active, tripId, rangeKey, reloadToken])
+
+ return { track: active ? track : null, status: active ? status : 'idle', fetchedAt, reload }
+}
diff --git a/client/src/components/Map/useDawarichTrailGL.ts b/client/src/components/Map/useDawarichTrailGL.ts
new file mode 100644
index 000000000..474579282
--- /dev/null
+++ b/client/src/components/Map/useDawarichTrailGL.ts
@@ -0,0 +1,90 @@
+import { useEffect } from 'react'
+import type { Map } from 'mapbox-gl'
+import type { GeoJSONSource } from 'mapbox-gl'
+import type { DawarichTrack } from '@trek/shared'
+import { DAWARICH_TRAIL_CASING, trailGeoJson, trailSegments } from './dawarichTrail'
+
+/**
+ * The recorded route on the GL renderer — the imperative twin of
+ * `DawarichTrailLayer`.
+ *
+ * The ids start with `trek-` because `applySatellite` finds the first layer
+ * whose id carries one of TREK's own prefixes and inserts the satellite tile
+ * beneath it. A layer named `dawarich-trail` would end up *under* the
+ * satellite image and simply not be there when someone switched basemap on.
+ *
+ * Everything is rebuilt on `style.load`, because the whole GL map is torn down
+ * and reassembled when the provider, the style or the token changes, and a
+ * source added outside that effect does not survive it.
+ */
+const SOURCE = 'trek-dawarich-trail'
+const CASING_LAYER = 'trek-dawarich-trail-casing'
+const LINE_LAYER = 'trek-dawarich-trail-line'
+
+export function useDawarichTrailGL(
+ map: Map | null,
+ ready: boolean,
+ track: DawarichTrack | null | undefined,
+ selectedDate?: string | null,
+ /** Insert beneath this layer, so the planned route stays on top. */
+ beforeId?: string,
+): void {
+ useEffect(() => {
+ if (!map || !ready) return
+
+ const data = trailGeoJson(trailSegments(track ?? null, selectedDate))
+
+ const draw = () => {
+ if (!map.isStyleLoaded()) return
+ const existing = map.getSource(SOURCE) as GeoJSONSource | undefined
+ if (existing) {
+ // setData rather than remove-and-add: a rebuilt source flickers, and a
+ // refresh every two minutes would flicker every two minutes.
+ existing.setData(data as never)
+ return
+ }
+ map.addSource(SOURCE, { type: 'geojson', data: data as never })
+ const anchor = beforeId && map.getLayer(beforeId) ? beforeId : undefined
+ map.addLayer(
+ {
+ id: CASING_LAYER,
+ type: 'line',
+ source: SOURCE,
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
+ // theme-lint-disable — map paint
+ paint: { 'line-color': DAWARICH_TRAIL_CASING, 'line-width': 6, 'line-opacity': 0.55 },
+ },
+ anchor,
+ )
+ map.addLayer(
+ {
+ id: LINE_LAYER,
+ type: 'line',
+ source: SOURCE,
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
+ paint: {
+ // Per-day colour off the feature, with the same fallback the GPX
+ // layer uses when a feature somehow carries none.
+ 'line-color': ['coalesce', ['get', 'color'], DAWARICH_TRAIL_CASING],
+ 'line-width': 3,
+ 'line-opacity': 0.85,
+ // Dashed, so the recording is distinguishable from the planned
+ // route without relying on colour alone.
+ 'line-dasharray': [2, 1.6],
+ },
+ },
+ anchor,
+ )
+ }
+
+ draw()
+ map.on('style.load', draw)
+ return () => {
+ map.off('style.load', draw)
+ for (const layer of [LINE_LAYER, CASING_LAYER]) {
+ if (map.getLayer(layer)) map.removeLayer(layer)
+ }
+ if (map.getSource(SOURCE)) map.removeSource(SOURCE)
+ }
+ }, [map, ready, track, selectedDate, beforeId])
+}
diff --git a/client/src/components/Planner/DayPlanSidebar.test.tsx b/client/src/components/Planner/DayPlanSidebar.test.tsx
index ab0470dc1..06fcb4161 100644
--- a/client/src/components/Planner/DayPlanSidebar.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.test.tsx
@@ -512,13 +512,16 @@ describe('DayPlanSidebar', () => {
const onToggleConnection = vi.fn()
render()
// No map-connections toggle on transit rows — the expander replaces it.
- expect(screen.queryByTitle(/connections/i)).not.toBeInTheDocument()
+ expect(screen.queryByLabelText(/connections/i)).not.toBeInTheDocument()
// Collapsed: no stop names beyond the chips.
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
await user.click(screen.getByLabelText('Expand'))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
expect(screen.getByText(/Platform 2/)).toBeInTheDocument()
- await user.click(screen.getByLabelText('Collapse'))
+ // The day's own chevron carries the same accessible name, so pick the one
+ // that is not in the day's action block.
+ const rowCollapse = screen.getAllByLabelText('Collapse').find(el => !el.closest('.dp-day-actions'))!
+ await user.click(rowCollapse)
expect(screen.queryByText('Alexanderplatz')).not.toBeInTheDocument()
})
@@ -2159,7 +2162,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
- const pencil = screen.getByTitle(/edit/i)
+ const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditReservation).toHaveBeenCalledWith(res)
expect(onEditTransport).not.toHaveBeenCalled()
@@ -2177,7 +2180,7 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
onEditReservation, onEditTransport,
})} />)
- const pencil = screen.getByTitle(/edit/i)
+ const pencil = screen.getByLabelText(/edit/i)
await user.click(pencil)
expect(onEditTransport).toHaveBeenCalledWith(res)
expect(onEditReservation).not.toHaveBeenCalled()
@@ -3435,7 +3438,7 @@ describe('DayPlanSidebar', () => {
const { rerender } = render()
- const show = screen.getByTitle('Show booking routes')
+ const show = screen.getByLabelText('Show booking routes')
await user.click(show)
expect(onToggleConnection).toHaveBeenCalledWith(510)
fireEvent.mouseEnter(show)
@@ -3444,7 +3447,7 @@ describe('DayPlanSidebar', () => {
rerender()
- expect(screen.getByTitle('Hide booking routes')).toBeInTheDocument()
+ expect(screen.getByLabelText('Hide booking routes')).toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-157: a non-transport booking row opens the reservation editor, unless read-only', async () => {
@@ -3560,9 +3563,9 @@ describe('DayPlanSidebar', () => {
})} />)
expect(screen.getByText('08:30 – 10:05')).toBeInTheDocument()
expect(screen.getByText('Air France AF1235')).toBeInTheDocument()
- await user.click(screen.getByTitle('Show booking routes'))
+ await user.click(screen.getByLabelText('Show booking routes'))
expect(onToggleConnection).toHaveBeenCalledWith(520)
- const edit = screen.getByTitle('Edit')
+ const edit = screen.getByLabelText('Edit')
fireEvent.mouseEnter(edit)
fireEvent.mouseLeave(edit)
await user.click(edit)
@@ -3608,7 +3611,7 @@ describe('DayPlanSidebar', () => {
expect(screen.getByText(/Reservation confirmed/)).toBeInTheDocument()
expect(screen.getByText(/Reservation pending/)).toBeInTheDocument()
// Earliest first, so the first pencil belongs to the parking pass.
- await user.click(screen.getAllByTitle('Edit')[0])
+ await user.click(screen.getAllByLabelText('Edit')[0])
expect(onEditReservation).toHaveBeenCalledWith(parking)
})
@@ -3636,12 +3639,12 @@ describe('DayPlanSidebar', () => {
days: [day], places: [place], assignments: { '10': [a] }, onAddBookingToAssignment,
})} />)
const row = dragRow(screen.getByText('Hover place'))
- expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
fireEvent.mouseEnter(row)
- await user.click(screen.getByTitle('Add booking'))
+ await user.click(screen.getByLabelText('Add booking'))
expect(onAddBookingToAssignment).toHaveBeenCalledWith(10, 11)
fireEvent.mouseLeave(row)
- expect(screen.queryByTitle('Add booking')).not.toBeInTheDocument()
+ expect(screen.queryByLabelText('Add booking')).not.toBeInTheDocument()
})
it('FE-PLANNER-DAYPLAN-166: the lock tooltip switches once a stop is pinned, and unlocking clears it', async () => {
@@ -3797,7 +3800,7 @@ describe('DayPlanSidebar', () => {
],
}
render()
- const connector = await screen.findByTitle('Change travel mode')
+ const connector = await screen.findByLabelText('Change travel mode')
await user.click(connector)
await user.click(contextMenu().getByRole('button', { name: 'Walking' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenCalledWith(1, 11, 'walking')
@@ -3805,7 +3808,7 @@ describe('DayPlanSidebar', () => {
'10': [expect.objectContaining({ id: 11, leg_transport_mode: 'walking' }), expect.objectContaining({ id: 12 })],
})
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Use day default' }))
expect(vi.mocked(assignmentsApi.updateTransport)).toHaveBeenLastCalledWith(1, 11, null)
})
@@ -3821,7 +3824,7 @@ describe('DayPlanSidebar', () => {
],
}
render()
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Origin/destination + this stop's departure time, resolved back from the leg coords.
expect(onPlanTransitLeg).toHaveBeenCalledWith({
@@ -3842,7 +3845,7 @@ describe('DayPlanSidebar', () => {
],
}
render()
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().queryByRole('button', { name: 'Public transit' })).not.toBeInTheDocument()
})
@@ -3866,7 +3869,7 @@ describe('DayPlanSidebar', () => {
}
render()
// The morning bookend (hotel -> first stop) is the first connector in the list.
- const connectors = await screen.findAllByTitle('Change travel mode')
+ const connectors = await screen.findAllByLabelText('Change travel mode')
await user.click(connectors[0])
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({
@@ -3898,7 +3901,7 @@ describe('DayPlanSidebar', () => {
],
}
render()
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Public transit' }))
// Day 11's own 16:30, not day 10's 09:00 (a trip-wide coord index would leak it).
expect(onPlanTransitLeg).toHaveBeenCalledWith(expect.objectContaining({ dayId: 11, time: '16:30' }))
@@ -3918,7 +3921,7 @@ describe('DayPlanSidebar', () => {
],
}
render()
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
await user.click(contextMenu().getByRole('button', { name: 'Driving' }))
await waitFor(() => expect(mockToast.error).toHaveBeenCalledWith('nope'))
expect(refreshDays).toHaveBeenCalledWith(1)
@@ -3942,7 +3945,7 @@ describe('DayPlanSidebar', () => {
})} />)
await user.click(screen.getByRole('button', { name: 'EV eco' }))
expect(onSetRouteProfile).toHaveBeenCalledWith('plugin:ev/eco')
- await user.click(await screen.findByTitle('Change travel mode'))
+ await user.click(await screen.findByLabelText('Change travel mode'))
expect(contextMenu().getByRole('button', { name: 'EV eco' })).toBeInTheDocument()
})
@@ -4025,7 +4028,7 @@ describe('DayPlanSidebar', () => {
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
const onAddTransport = vi.fn()
render()
- await user.click(screen.getByTitle('Add transport'))
+ await user.click(screen.getByLabelText('Add transport'))
expect(onAddTransport).toHaveBeenCalledWith(10)
})
@@ -4244,7 +4247,7 @@ describe('DayPlanSidebar', () => {
fireEvent.mouseEnter(expand)
fireEvent.mouseLeave(expand)
await user.click(expand)
- expect(screen.getByLabelText('Collapse')).toBeInTheDocument()
+ expect(screen.getAllByLabelText('Collapse').some(el => !el.closest('.dp-day-actions'))).toBe(true)
await user.click(cardRow(screen.getByText('U2 to Zoo')))
expect(await screen.findByText('Alexanderplatz')).toBeInTheDocument()
})
@@ -4490,10 +4493,13 @@ describe('the day route-tools row', () => {
it('keeps the optimize action reachable by name without printing it', () => {
render()
const btn = screen.getByRole('button', { name: /optimize/i })
- // The assertion that pins the change: named, but no visible label.
+ // The assertion that pins the change: named, but no visible label. The name
+ // is `aria-label` rather than `title` — the hover text is TREK's own tooltip
+ // now, and a browser tooltip is not an accessible name anyway.
expect(btn).toBeInTheDocument()
expect(btn.textContent?.trim()).toBe('')
- expect(btn.getAttribute('title')).toBeTruthy()
+ expect(btn.getAttribute('aria-label')).toBeTruthy()
+ expect(btn.getAttribute('title')).toBeNull()
})
/* The click itself is already pinned by FE-PLANNER-DAYPLAN-038 above, which
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index 2017b720d..204182148 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -29,6 +29,7 @@ import { usePluginStore } from '../../store/pluginStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { placeToSaveTarget } from '../Collections/saveTarget'
import { useTranslation } from '../../i18n'
+import { Tooltip } from '../shared/Tooltip'
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
import {
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getDisplayTimeForDay, getTransportRouteEndpoints,
@@ -1189,8 +1190,34 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
}
}
+/**
+ * Below this the plan sidebar has no room for a word on the route button.
+ *
+ * The word goes early on purpose: it shares its row with two export buttons and
+ * an optimise button, and a "Route" squeezed to four letters and an ellipsis is
+ * worse than the icon that means the same thing. The icon keeps the accessible
+ * name, so nothing is lost but the letters.
+ */
+const NARROW_PLAN_PX = 320
+
const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) {
const S = useDayPlanSidebar(props)
+ // The plan sidebar is resizable. Below this the route button's own word is the
+ // first thing that stops fitting beside the two export buttons — the icon says
+ // the same thing, and the accessible name still spells it out.
+ //
+ // A callback ref rather than `useRef`, so the measurement happens when the
+ // element exists rather than on a mount that may render nothing yet.
+ const [panel, setPanel] = useState(null)
+ const [narrowPanel, setNarrowPanel] = useState(false)
+ useEffect(() => {
+ if (!panel || typeof ResizeObserver === 'undefined') return
+ const measure = (): void => setNarrowPanel(panel.clientWidth < NARROW_PLAN_PX)
+ measure()
+ const ro = new ResizeObserver(measure)
+ ro.observe(panel)
+ return () => ro.disconnect()
+ }, [panel])
// A stable key for the current selection. A multi-day place renders one row per
// day (same place_id, different assignment ids); selecting it by place_id alone
// (e.g. clicking an accommodation) marks every one of those rows selected, so a
@@ -1504,7 +1531,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
}
return (
-
diff --git a/client/src/components/Planner/PlaceInspector.tsx b/client/src/components/Planner/PlaceInspector.tsx
index cebbadeee..0694657cc 100644
--- a/client/src/components/Planner/PlaceInspector.tsx
+++ b/client/src/components/Planner/PlaceInspector.tsx
@@ -22,6 +22,8 @@ import { useSettingsStore } from '../../store/settingsStore'
import { useAddonStore } from '../../store/addonStore'
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
import { getCategoryIcon } from '../shared/categoryIcons'
+import DawarichIcon from '../shared/DawarichIcon'
+import { Tooltip } from '../shared/Tooltip'
import { useToast } from '../shared/Toast'
import { useTranslation, translateApiError } from '../../i18n'
import { usePluginStore } from '../../store/pluginStore'
@@ -796,6 +798,17 @@ function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameIn
style={{ fontWeight: 600, fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
>{place.name}
)}
+ {/* Where the place came from, when it did not come from somebody
+ typing it: a stay accepted out of their own recordings. The
+ mark alone — the name beside it is already the place's name,
+ and a word here would only repeat the tooltip. */}
+ {place.source === 'dawarich' && (
+
+
+
+
+
+ )}
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index 88023112b..fcd40f0cf 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -10,6 +10,10 @@ import { MobileDayPickerSheet } from './PlacesSidebarMobileDayPicker'
import { ListImportModal } from './PlacesSidebarListImportModal'
import { PlacesBulkCategoryModal } from './PlacesBulkCategoryModal'
import SaveTripPlacesToListModal from '../Collections/SaveTripPlacesToListModal'
+import DawarichSuggestionsPanel from '../Dawarich/DawarichSuggestionsPanel'
+import { formatDayOption } from '../Dawarich/dawarichSuggestionModel'
+import { refreshTripAfterAccept } from '../Dawarich/dawarichTripRefresh'
+import { useTranslation } from '../../i18n'
const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProps) {
const S = usePlacesSidebar(props)
@@ -19,8 +23,10 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile, tripId, pushUndo,
ctxMenu, isMobile, pendingDeleteIds, setPendingDeleteIds, onBulkDeleteConfirm,
categories, selectedIds, exitSelectMode, onBulkChangeCategory, categoryPickerOpen, setCategoryPickerOpen,
- collectionsEnabled, saveToListOpen, setSaveToListOpen,
+ collectionsEnabled, saveToListOpen, setSaveToListOpen, days,
} = S
+ // The sidebar hook carries `t` but not the locale; day labels need both.
+ const { locale } = useTranslation()
// Below lg the places sit in their own tab with no plan beside them to drag
// into. A coarse pointer no longer disables the drag on its own — tablets
// reach it through a long press (#1616).
@@ -59,6 +65,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar(props: PlacesSidebarProp
)}
+ {/* Stays Dawarich recorded on these dates, waiting to be reviewed (#2279).
+ Above the list because that is what they become, and the panel renders
+ nothing at all when there is nothing pending — an integration that is
+ connected and quiet should be invisible rather than a permanent empty
+ card. */}
+
+ days.map(day => ({
+ id: day.id,
+ ...formatDayOption(day.day_number, day.date, locale, t),
+ }))}
+ // The place it just created belongs on the map and in the list
+ // now, not after a reload.
+ onAccepted={() => { void refreshTripAfterAccept(tripId) }}
+ initiallyCollapsed
+ />
+
+
{/* Liste */}
diff --git a/client/src/components/Settings/AirTrailConnectionSection.tsx b/client/src/components/Settings/AirTrailConnectionSection.tsx
index 1482217c7..861142069 100644
--- a/client/src/components/Settings/AirTrailConnectionSection.tsx
+++ b/client/src/components/Settings/AirTrailConnectionSection.tsx
@@ -3,6 +3,7 @@ import { Plane, Save } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { airtrailApi } from '../../api/client'
+import AirTrailIcon from '../shared/AirTrailIcon'
import Section from './Section'
import ToggleSwitch from './ToggleSwitch'
@@ -78,7 +79,7 @@ export default function AirTrailConnectionSection(): React.ReactElement {
const canSave = !!url.trim() && (connected || !!apiKey.trim())
return (
-
+
diff --git a/client/src/components/Settings/ApiKeysSection.tsx b/client/src/components/Settings/ApiKeysSection.tsx
index b33be69f2..4c0659507 100644
--- a/client/src/components/Settings/ApiKeysSection.tsx
+++ b/client/src/components/Settings/ApiKeysSection.tsx
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react'
import { KeyRound, Plus, Trash2, Copy, Check } from 'lucide-react'
+import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared'
import Section from './Section'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
@@ -24,6 +25,9 @@ interface ApiKey {
token_prefix: string
created_at: string
last_used_at: string | null
+ /** 'all' for every key minted before scopes existed, and for any key created without narrowing. */
+ scope_mode?: 'all' | 'limited'
+ scopes?: PublicApiScope[]
}
export default function ApiKeysSection(): React.ReactElement {
@@ -37,6 +41,12 @@ export default function ApiKeysSection(): React.ReactElement {
const [created, setCreated] = useState(null)
const [deleteId, setDeleteId] = useState(null)
const [copied, setCopied] = useState(false)
+ /**
+ * What the new key may read. Starts as everything, because that is what a key
+ * did before this existed and what most integrations want — narrowing is a
+ * deliberate act, and the dialog should not make the common case work harder.
+ */
+ const [newScopes, setNewScopes] = useState>(new Set(PUBLIC_API_SCOPES))
useEffect(() => {
authApi.apiKeys.list().then(d => setKeys(d.tokens || [])).catch(() => {})
@@ -46,10 +56,23 @@ export default function ApiKeysSection(): React.ReactElement {
if (!newName.trim() || creating) return
setCreating(true)
try {
- const d = await authApi.apiKeys.create(newName.trim())
+ // All of them selected means "no narrowing", which is what the server
+ // stores as `all` — sending the full list would record it as a limited key
+ // that happens to allow everything, and a section added in a later version
+ // would then be refused for a key nobody meant to restrict.
+ const narrowed = newScopes.size < PUBLIC_API_SCOPES.length ? [...newScopes] : undefined
+ const d = await authApi.apiKeys.create(newName.trim(), narrowed)
setCreated(d.token.raw_token)
setKeys(prev => [
- { id: d.token.id, name: d.token.name, token_prefix: d.token.token_prefix, created_at: d.token.created_at, last_used_at: null },
+ {
+ id: d.token.id,
+ name: d.token.name,
+ token_prefix: d.token.token_prefix,
+ created_at: d.token.created_at,
+ last_used_at: null,
+ scope_mode: d.token.scope_mode,
+ scopes: d.token.scopes,
+ },
...prev,
])
setNewName('')
@@ -83,6 +106,16 @@ export default function ApiKeysSection(): React.ReactElement {
setModalOpen(false)
setCreated(null)
setNewName('')
+ setNewScopes(new Set(PUBLIC_API_SCOPES))
+ }
+
+ const toggleScope = (scope: PublicApiScope) => {
+ setNewScopes(prev => {
+ const next = new Set(prev)
+ if (next.has(scope)) next.delete(scope)
+ else next.add(scope)
+ return next
+ })
}
return (
@@ -114,6 +147,14 @@ export default function ApiKeysSection(): React.ReactElement {
· {t('settings.apiKeys.usedAt')} {new Date(key.last_used_at).toLocaleDateString(locale)}
)}
+ {/* What the key may read. Shown on the row rather than behind a
+ detail view: the whole reason to narrow a key is to be able
+ to see later that you did. */}
+
{t('common.cancel')}
-
{creating ? t('settings.apiKeys.modal.creating') : t('settings.apiKeys.modal.create')}
diff --git a/client/src/components/Settings/DawarichConnectionSection.tsx b/client/src/components/Settings/DawarichConnectionSection.tsx
new file mode 100644
index 000000000..46e7a78b0
--- /dev/null
+++ b/client/src/components/Settings/DawarichConnectionSection.tsx
@@ -0,0 +1,193 @@
+import React from 'react'
+import { Save, Plug, RefreshCw, Unplug } from 'lucide-react'
+import { useTranslation } from '../../i18n'
+import { useDawarichConnection } from '../../hooks/useDawarichConnection'
+import DawarichIcon from '../shared/DawarichIcon'
+import Section from './Section'
+import ToggleSwitch from './ToggleSwitch'
+
+/**
+ * Settings → Integrations → Dawarich.
+ *
+ * All of the behaviour is in `useDawarichConnection`, shared with the phone
+ * twin; this file is markup. The layout follows the AirTrail and LLM sections
+ * so the three read as one shelf, but the classes are the semantic tokens
+ * rather than the raw `slate-*` those two still carry — a new surface has to
+ * follow the user's accent and colour scheme.
+ *
+ * The status block below the fields is the part that earns its space: a
+ * connection to somebody else's server fails in ways they can act on — a wrong
+ * key, a certificate, an instance that is simply off — and "not connected" with
+ * no reason is the version of this card that generates support questions.
+ */
+export default function DawarichConnectionSection(): React.ReactElement {
+ const { t, locale } = useTranslation()
+ const S = useDawarichConnection()
+
+ return (
+
+
+
+ )
+}
+
+/**
+ * Where the connection stands, in the place where it helps someone decide what
+ * to do next: what the last sync did, and which parts of their instance TREK
+ * could actually reach.
+ *
+ * Its own component because the phone twin renders the same three facts in a
+ * different frame, and because it is the bit that keeps growing.
+ */
+function DawarichConnectionStatus({
+ state,
+ locale,
+}: {
+ state: ReturnType
+ locale: string
+}): React.ReactElement | null {
+ const { t } = useTranslation()
+ if (!state.connected && !state.probeMessage) return null
+
+ const missing: string[] = []
+ if (state.capabilities) {
+ if (!state.capabilities.visits) missing.push(t('dawarich.capability.visits'))
+ if (!state.capabilities.tracks && !state.capabilities.points) missing.push(t('dawarich.capability.track'))
+ if (!state.capabilities.locations) missing.push(t('dawarich.capability.locations'))
+ if (!state.capabilities.visitedCities) missing.push(t('dawarich.capability.visitedCities'))
+ }
+
+ return (
+
)
diff --git a/client/src/mobile/screens/journey/MJourneyDetail.tsx b/client/src/mobile/screens/journey/MJourneyDetail.tsx
index 058b5ebdd..14316f655 100644
--- a/client/src/mobile/screens/journey/MJourneyDetail.tsx
+++ b/client/src/mobile/screens/journey/MJourneyDetail.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
+import { useSearchParams } from 'react-router'
+import { ChevronLeft, MapPin, Grid3x3, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
@@ -9,6 +10,10 @@ import { ProviderPicker } from '../../../components/Journey/JourneyDetailPagePro
import { photoUrl } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
import { useJourneyDetail } from '../../../pages/journeyDetail/useJourneyDetail'
import { useJourneyStore } from '../../../store/journeyStore'
+import { useAddonStore } from '../../../store/addonStore'
+import DawarichIcon from '../../../components/shared/DawarichIcon'
+import DawarichSuggestionsPanel from '../../../components/Dawarich/DawarichSuggestionsPanel'
+import { FormSheetHeader } from '../trip/sheets/PlSheetChrome'
import type { JourneyEntry, GalleryPhoto } from '../../../store/journeyStore'
import { useAuthStore } from '../../../store/authStore'
import { journeyApi, addonsApi, memoriesApi } from '../../../api/client'
@@ -43,6 +48,15 @@ export default function MJourneyDetail() {
loadJourney, updateEntry, deleteEntry, uploadPhotos,
} = useJourneyDetail()
+ // The dock's FAB is a sibling of this screen: on the Gallery it becomes the
+ // upload button, so it has to know which tab is open — and it stops knowing
+ // when this screen goes away.
+ const setMobileGalleryOpen = useJourneyStore(state => state.setMobileGalleryOpen)
+ useEffect(() => {
+ setMobileGalleryOpen(view === 'gallery')
+ return () => setMobileGalleryOpen(false)
+ }, [view, setMobileGalleryOpen])
+
const mapRef = useRef(null)
const carouselRef = useRef(null)
const cardRefs = useRef
- {view === 'gallery' && canEditEntries && (
- (availableProviders.length > 0 ? setShowUploadMenu(true) : galleryFileRef.current?.click())}
- disabled={uploading}
+ {/* Uploading lives on the dock's FAB while the Gallery is open — the
+ one big action on the screen. A second button up here would be the
+ same thing twice, so it only appears while an upload is running,
+ as its progress. */}
+ {view === 'gallery' && canEditEntries && uploading && (
+
- {uploading ? (
-
- ) : (
-
- )}
-
+
+
)}
+ {/* The stays Dawarich recorded for these dates, as candidate entries. Over
+ the map rather than in the header: it belongs to what is on the map,
+ and the header is already three controls wide on a phone. Sits above
+ the card rail so it never covers the card somebody is reading. */}
+ {dawarichEnabled && canEditEntries && view === 'timeline' && (
+ setDawarichOpen(true)}
+ aria-label={t('dawarich.suggestions.title')}
+ className="absolute right-4 z-[9] flex h-[46px] w-[46px] items-center justify-center overflow-hidden rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] shadow-[0_6px_18px_-8px_rgba(0,0,0,.35)] bottom-[calc(var(--bottom-nav-h,84px)+16px+128px)]"
+ >
+
+
+ )}
+
+ {dawarichOpen && current && (
+ setDawarichOpen(false)} variant="bottom" material="glass" ariaLabel={t('dawarich.suggestions.title')}>
+
+ {/* Journal entries and nothing else: a journey is for writing, and
+ the trip planner already offers the same stay as a place. */}
+ { void loadJourney(current.id) }}
+ />
+
+ {/* TREK's own picker, like the time field beside it: a native
+ `` paints itself from the OS locale and takes
+ no theme, so the two sat in one row looking like two apps. */}
+
-
+
{t('mobileJourney.time')}
{/* A native paints 12h or 24h from the browser
locale, whatever the user picked in settings (#2067). The picker
diff --git a/client/src/mobile/screens/settings/MDawarichConnectionSection.tsx b/client/src/mobile/screens/settings/MDawarichConnectionSection.tsx
new file mode 100644
index 000000000..27ca4c635
--- /dev/null
+++ b/client/src/mobile/screens/settings/MDawarichConnectionSection.tsx
@@ -0,0 +1,120 @@
+import React from 'react'
+import { Plug, Save, RefreshCw, Unplug } from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import { useDawarichConnection } from '../../../hooks/useDawarichConnection'
+import { MSetCard, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi'
+import MToggle from '../../components/MToggle'
+
+/**
+ * Phone twin of components/Settings/DawarichConnectionSection.
+ *
+ * Presentation only: every piece of behaviour lives in `useDawarichConnection`,
+ * which both shells share. That is not tidiness — SonarCloud allows 3%
+ * duplicated lines on a PR's new code, and a second copy of the connect / test /
+ * sync logic would spend the whole budget on its own.
+ */
+export default function MDawarichConnectionSection(): React.ReactElement {
+ const { t, locale } = useTranslation()
+ const S = useDawarichConnection()
+
+ return (
+
+ {t('dawarich.intro')}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/mobile/screens/settings/MSettingsIntegrations.tsx b/client/src/mobile/screens/settings/MSettingsIntegrations.tsx
index 40308a3a4..208845baa 100644
--- a/client/src/mobile/screens/settings/MSettingsIntegrations.tsx
+++ b/client/src/mobile/screens/settings/MSettingsIntegrations.tsx
@@ -2,6 +2,7 @@ import { useEffect } from 'react'
import { useAddonStore } from '../../../store/addonStore'
import MPhotoProvidersSection from './MPhotoProvidersSection'
import MAirTrailConnectionSection from './MAirTrailConnectionSection'
+import MDawarichConnectionSection from './MDawarichConnectionSection'
import MLlmConnectionSection from './MLlmConnectionSection'
import MSettingsMcp from './MSettingsMcp'
import { useAuthStore } from '../../../store/authStore'
@@ -17,6 +18,7 @@ export default function MSettingsIntegrations() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
+ const dawarichEnabled = addonEnabled('dawarich')
const managed = useAuthStore((s) => s.managed)
useEffect(() => {
@@ -27,6 +29,9 @@ export default function MSettingsIntegrations() {
<>
{airtrailEnabled && }
+ {/* Reaches a server the reader runs themselves, which a managed install has
+ no route to — the same gate the desktop shell puts on AirTrail. */}
+ {dawarichEnabled && !managed && }
{/* Which model reads a booking, and what that costs, comes with the instance on
a managed install. The per-user fallback exists for people who supply their
own key, and there nobody does. */}
diff --git a/client/src/mobile/screens/trip/map/MMapArea.tsx b/client/src/mobile/screens/trip/map/MMapArea.tsx
index 86f2324b7..a8c695249 100644
--- a/client/src/mobile/screens/trip/map/MMapArea.tsx
+++ b/client/src/mobile/screens/trip/map/MMapArea.tsx
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { MapViewAuto } from '../../../../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../../../../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../../../../components/Map/TripRouteOverview'
+import { DawarichTrailPill } from '../../../../components/Map/DawarichTrailPill'
import PoiCategoryPill from '../../../../components/Map/PoiCategoryPill'
import { usePoiExplore } from '../../../../components/Map/usePoiExplore'
import { useSettingsStore } from '../../../../store/settingsStore'
@@ -44,6 +45,7 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
- {planner.overviewActive && (
+ {!planner.roadtripActive && planner.overviewActive && (
)}
-
+ {!planner.roadtripActive && (
+
+ )}
+ {/* Kept in road-trip mode: the recorded route beside the planned one is
+ exactly the comparison a drive invites. */}
+ {planner.dawarichEnabled && (
+
+ )}
)}
diff --git a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
index 46b9557df..a3880d089 100644
--- a/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
+++ b/client/src/mobile/screens/trip/places/MPlacesBrowser.tsx
@@ -13,6 +13,10 @@ import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
import { resolveTrackColor } from '../../../../components/Map/trackColors'
import MConfirmSheet from '../../settings/MConfirmSheet'
import type { MPlacesBrowserProps } from '../MTripShell'
+import DawarichSuggestionsPanel from '../../../../components/Dawarich/DawarichSuggestionsPanel'
+import { formatDayOption } from '../../../../components/Dawarich/dawarichSuggestionModel'
+import { refreshTripAfterAccept } from '../../../../components/Dawarich/dawarichTripRefresh'
+import { useTranslation } from '../../../../i18n'
import type { Place } from '../../../../types'
import MPlacesBulkCategorySheet from './MPlacesBulkCategorySheet'
import MPlacesSaveToCollectionSheet from './MPlacesSaveToCollectionSheet'
@@ -31,6 +35,8 @@ import { filterPool, firstPlannedDayNumbers, plannedPlaceIds } from './placesBro
*/
export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps) {
const { t, places, categories, assignments, days, trip } = planner
+ // The planner hook carries `t` but not the locale; day labels need both.
+ const { locale } = useTranslation()
const canEditPlaces = planner.can('place_edit', trip)
const collectionsEnabled = useAddonStore(s => s.isEnabled('collections'))
@@ -231,6 +237,24 @@ export default function MPlacesBrowser({ planner, shell }: MPlacesBrowserProps)
)}
+ {/* Stays Dawarich recorded on these dates (#2279). Same component as the
+ desktop rail — the rows are the same rows, and the panel renders
+ nothing when there is nothing pending. */}
+
+ days.map(day => ({
+ id: day.id,
+ ...formatDayOption(day.day_number, day.date, locale, t),
+ }))}
+ // The place it just created belongs on the map and in the list
+ // now, not after a reload.
+ onAccepted={() => { void refreshTripAfterAccept(planner.tripId) }}
+ initiallyCollapsed
+ />
+
+ {place.name}
+ {/* Accepted out of the traveller's own recordings — the same
+ mark the desktop inspector carries. */}
+ {place.source === 'dawarich' && (
+
+
+
+ )}
+
{place.address && (
{place.address}
)}
diff --git a/client/src/pages/AtlasPage.tsx b/client/src/pages/AtlasPage.tsx
index 8693385a8..045360929 100644
--- a/client/src/pages/AtlasPage.tsx
+++ b/client/src/pages/AtlasPage.tsx
@@ -5,8 +5,10 @@ import Navbar from '../components/Layout/Navbar'
import apiClient from '../api/client'
import CustomSelect from '../components/shared/CustomSelect'
import EmptyState from '../components/shared/EmptyState'
-import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
+import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search, Check } from 'lucide-react'
import type { TranslationFn } from '../types'
+import { Tooltip } from '../components/shared/Tooltip'
+import DawarichAtlasSidePanel from '../components/Dawarich/DawarichAtlasSidePanel'
import { A2_TO_A3, countryCodeToFlag, findBucketDuplicate, isBucketDuplicateError, withCountryMarkedVisited, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
import { continentForCountry } from '@trek/shared'
import { useAtlas } from './atlas/useAtlas'
@@ -41,10 +43,10 @@ function AtlasPageDesktop(): React.ReactElement {
bucketMonth, setBucketMonth, bucketYear, setBucketYear,
bucketList, setBucketList, bucketTab, setBucketTab,
showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm,
- handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
+ handleAddBucketItem, handleDeleteBucketItem, handleClearBucketVisit, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
- bucketSearching, bucketSearch, setBucketSearch,
+ bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich,
} = useAtlas()
const toast = useToast()
// Solid surfaces when the user disabled transparency (read at render — the
@@ -62,6 +64,17 @@ function AtlasPageDesktop(): React.ReactElement {
)
}
+ // The glass both bottom panels are cut from. One object, because two panels
+ // side by side that differ by a blur radius look like a mistake.
+ const glassPanel: React.CSSProperties = {
+ background: noTransparency ? (dark ? '#15151c' : '#ffffff') : (dark ? 'rgba(10,10,15,0.55)' : 'rgba(255,255,255,0.2)'),
+ backdropFilter: 'blur(24px) saturate(180%)',
+ WebkitBackdropFilter: 'blur(24px) saturate(180%)',
+ border: '1px solid ' + (dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)'),
+ borderRadius: 20,
+ boxShadow: dark ? '0 8px 32px rgba(0,0,0,0.3)' : '0 8px 32px rgba(0,0,0,0.08)',
+ }
+
return (
@@ -121,26 +134,36 @@ function AtlasPageDesktop(): React.ReactElement {
- {/* Desktop Panel — bottom center, glass effect */}
+ {/* Desktop Panels — bottom center, glass effect.
+ Three columns so the Atlas panel stays exactly centred over the map
+ however wide its neighbour is: Dawarich sits in the left column,
+ right-aligned against the middle one, and stretches to its height. */}
+
@@ -463,6 +489,7 @@ interface SidebarContentProps {
setBucketForm: (f: { name: string; notes: string; lat: string; lng: string; target_date: string }) => void
onAddBucket: () => Promise
onDeleteBucket: (id: number) => Promise
+ onClearBucketVisit: (id: number) => void | Promise
onSearchBucket: () => Promise
onSelectBucketPoi: (result: any) => void
bucketSearchResults: any[]
@@ -478,7 +505,7 @@ interface SidebarContentProps {
dark: boolean
}
-function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
+function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onTripClick, onUnmarkCountry, bucketList, bucketTab, setBucketTab, showBucketAdd, setShowBucketAdd, bucketForm, setBucketForm, onAddBucket, onDeleteBucket, onClearBucketVisit, onSearchBucket, onSelectBucketPoi, bucketSearchResults, setBucketSearchResults, bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear, bucketSearching, bucketSearch, setBucketSearch, t, dark }: SidebarContentProps): React.ReactElement {
const { language } = useTranslation()
const statsContentRef = useRef(null)
const bucketSearchRowRef = useRef(null)
@@ -552,6 +579,41 @@ function SidebarContent({ data, stats, countries, selectedCountry, countryDetail
return {label}
})()}
{!item.target_date && item.notes && {item.notes}}
+ {/* Reached (#2279). A tick rather than removing the entry: a wish that
+ came true is the part of the list worth keeping, and the source is
+ named so a recording-derived tick is not mistaken for a manual one. */}
+ {item.visited_at && (
+
+ {/* A tick that came from a recording can be taken back here: the
+ wishlist is curated by hand, and a suggestion nobody can undo
+ is not a suggestion. A tick somebody set themselves is text. */}
+ {item.visited_source === 'dawarich' ? (
+ { void onClearBucketVisit(item.id) }}
+ aria-label={t('dawarich.bucket.clearVisit')}
+ className="text-success mt-0.5 flex items-center gap-1 hover:opacity-70"
+ style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', background: 'none', border: 'none', padding: 0, cursor: 'pointer', fontFamily: 'inherit' }}
+ >
+
+ {new Date(item.visited_at).toLocaleDateString(language)}
+
+ ) : (
+
+
+ {new Date(item.visited_at).toLocaleDateString(language)}
+
+ )}
+
+ )}
onDeleteBucket(item.id)}
className="opacity-0 group-hover:opacity-100"
style={{ position: 'absolute', top: 4, right: 4, background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: tf, display: 'flex', transition: 'opacity 0.15s' }}>
diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx
index eec0a2f9a..0d555daf3 100644
--- a/client/src/pages/JourneyDetailPage.tsx
+++ b/client/src/pages/JourneyDetailPage.tsx
@@ -13,6 +13,7 @@ import {
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen, Image,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
+import DawarichSuggestionsPanel from '../components/Dawarich/DawarichSuggestionsPanel'
import MobileEntryView from '../components/Journey/MobileEntryView'
import { useJourneyStore } from '../store/journeyStore'
import { computeJourneyLifecycle } from '../utils/journeyLifecycle'
@@ -379,6 +380,19 @@ function JourneyDetailPageDesktop() {
{/* Timeline (desktop only — mobile uses fullscreen combined view above) */}
{!isMobile && (
+ {/* Stays Dawarich recorded over this journal's dates, waiting to
+ be turned into entries (#2279). Above the timeline because
+ that is what they become; renders nothing when there is
+ nothing pending. */}
+ {canEditEntries && (
+ { void loadJourney(current.id) }}
+ />
+ )}
+
{sortedDates.length === 0 && (
)}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index 511f9f431..8e6418d2a 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -7,6 +7,7 @@ import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
import { TripRouteOverviewPill, TripRouteOverviewPanel } from '../components/Map/TripRouteOverview'
+import { DawarichTrailPill } from '../components/Map/DawarichTrailPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import RoadtripModeSwitch from '../components/Roadtrip/RoadtripModeSwitch'
@@ -255,6 +256,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewActive, tripOverview, toggleOverview, overviewShown,
+ dawarichEnabled, dawarichTrailShown, toggleDawarichTrail, dawarichTrail,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
@@ -406,6 +408,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
- {overviewActive && (
+ {!roadtripActive && overviewActive && (
)}
-
+ {!roadtripActive && (
+
+ )}
+ {/* Stays in road-trip mode, unlike the overview: the route that was
+ actually driven is the thing you most want beside the planned
+ one. It is drawn, never applied — correcting the plan from the
+ recording is a different feature and deliberately not this one. */}
+ {dawarichEnabled && (
+
+ )}
)}
diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx
index 02ea08be2..3dc040411 100644
--- a/client/src/pages/TripPlannerPage.wiring.test.tsx
+++ b/client/src/pages/TripPlannerPage.wiring.test.tsx
@@ -154,6 +154,13 @@ function baseState(): HookState {
askRefuel: vi.fn(),
acceptRefuel: vi.fn(),
followTrack: { dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, outcome: null, apply: vi.fn(), clear: vi.fn(), viaCount: 0, available: false },
+ // The recorded-route overlay (#2279). Off and empty: the page reads
+ // `dawarichTrail.track` unconditionally, so the fixture has to carry the
+ // shape the hook returns even when the addon is not in play.
+ dawarichEnabled: false,
+ dawarichTrailShown: false,
+ toggleDawarichTrail: vi.fn(),
+ dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
roadtripViaCounts: {},
stopDraft: null,
setStopDraft: vi.fn(),
diff --git a/client/src/pages/atlas/atlasModel.ts b/client/src/pages/atlas/atlasModel.ts
index 3aab6ec15..95d55992a 100644
--- a/client/src/pages/atlas/atlasModel.ts
+++ b/client/src/pages/atlas/atlasModel.ts
@@ -105,6 +105,10 @@ export interface BucketItem {
country_code: string | null
notes: string | null
target_date: string | null
+ /** ISO-8601 of the stay that fulfilled this wish, or null while it is still a wish. */
+ visited_at?: string | null
+ /** Who decided it was reached: 'manual' or 'dawarich' (#2279). */
+ visited_source?: string | null
}
// Normalize a region name for matching: strip diacritics (the geocoder and the
diff --git a/client/src/pages/atlas/useAtlas.ts b/client/src/pages/atlas/useAtlas.ts
index d242116cf..bece9fdda 100644
--- a/client/src/pages/atlas/useAtlas.ts
+++ b/client/src/pages/atlas/useAtlas.ts
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState, useRef } from 'react'
+import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'
import { useNavigate } from 'react-router'
import { getIntlLanguage, getLocaleForLanguage, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
@@ -11,6 +11,8 @@ import L from 'leaflet'
import type { GeoJsonFeatureCollection } from '../../types'
import { A2_TO_A3, countryStatus, findBucketDuplicate, isBucketDuplicateError, isCountryVisible, normalizeRegionName, regionCacheEvictions, withCountryMarkedVisited, wishlistA3Codes, countryColor, REGION_CACHE_MAX, bucketTooltipWidth, bucketTooltipPlacement, bucketTooltipNeedsScroll, type AtlasData, type AtlasPlaceHit, type CountryDetail, type BucketItem } from './atlasModel'
import { continentForCountry, escapeHtml, type VisitStatus } from '@trek/shared'
+import { useGlassGlare } from '../../components/Atlas/useGlassGlare'
+import { dawarichApi } from '../../api/dawarich'
import { useToast } from '../../components/shared/Toast'
import { getApiErrorMessage } from '../../types'
@@ -105,29 +107,11 @@ export function useAtlas() {
// kept redrawing itself on every pan for the rest of the session (#1950).
const countryRendererRef = useRef(null)
const regionRendererRef = useRef(null)
- const glareRef = useRef(null)
- const borderGlareRef = useRef(null)
- const panelRef = useRef(null)
+ // The panel's hover light, shared with the Dawarich panel beside it.
+ const { panelRef, glareRef, borderGlareRef, onMouseMove: handlePanelMouseMove, onMouseLeave: handlePanelMouseLeave } =
+ useGlassGlare(dark)
const country_layer_by_a2_ref = useRef>({})
- const handlePanelMouseMove = (e: React.MouseEvent): void => {
- if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
- const rect = panelRef.current.getBoundingClientRect()
- const x = e.clientX - rect.left
- const y = e.clientY - rect.top
- // Subtle inner glow
- glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)`
- glareRef.current.style.opacity = '1'
- // Border glow that follows cursor
- borderGlareRef.current.style.opacity = '1'
- borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
- borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)`
- }
- const handlePanelMouseLeave = () => {
- if (glareRef.current) glareRef.current.style.opacity = '0'
- if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
- }
-
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [sidebarOpen, setSidebarOpen] = useState(true)
@@ -224,6 +208,30 @@ export function useAtlas() {
}, [geoData, resolveName])
// Load atlas data + bucket list
+ //
+ // Re-run on `atlasEpoch` so a confirmation made elsewhere on the page — the
+ // Dawarich card ticking wishes off or marking countries (#2279) — lands in the
+ // same numbers the map is drawing, rather than only after a reload.
+ const [atlasEpoch, setAtlasEpoch] = useState(0)
+ const reloadAfterDawarich = useCallback(() => setAtlasEpoch(epoch => epoch + 1), [])
+
+ /**
+ * Undo a wish that a recording ticked off.
+ *
+ * A suggestion that cannot be taken back is not a suggestion, and this one
+ * writes into the wishlist — the one list on this page somebody curates by
+ * hand. The row is kept; only the visit is cleared.
+ */
+ const handleClearBucketVisit = useCallback(async (itemId: number) => {
+ try {
+ await dawarichApi.clearBucketVisit(itemId)
+ setBucketList(prev => prev.map(item => (
+ item.id === itemId ? { ...item, visited_at: null, visited_source: null } : item
+ )))
+ } catch (err) {
+ toast.error(getApiErrorMessage(err, t('common.error')))
+ }
+ }, [t, toast])
useEffect(() => {
Promise.all([
apiClient.get('/addons/atlas/stats'),
@@ -233,7 +241,7 @@ export function useAtlas() {
setBucketList(bucketRes.data.items || [])
setLoading(false)
}).catch(() => setLoading(false))
- }, [])
+ }, [atlasEpoch])
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
// no third-party fetch from the browser). Even gzipped the payload is a few MB, so
@@ -1176,6 +1184,6 @@ export function useAtlas() {
handleAddBucketItem, handleDeleteBucketItem, handleBucketPoiSearch, handleSelectBucketPoi,
bucketSearchResults, setBucketSearchResults,
bucketPoiMonth, setBucketPoiMonth, bucketPoiYear, setBucketPoiYear,
- bucketSearching, bucketSearch, setBucketSearch,
+ bucketSearching, bucketSearch, setBucketSearch, reloadAfterDawarich, handleClearBucketVisit,
}
}
diff --git a/client/src/pages/journeyDetail/useJourneyDetail.ts b/client/src/pages/journeyDetail/useJourneyDetail.ts
index d2f655522..36ea0fa7d 100644
--- a/client/src/pages/journeyDetail/useJourneyDetail.ts
+++ b/client/src/pages/journeyDetail/useJourneyDetail.ts
@@ -13,6 +13,8 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
import type { JourneyEntry } from '../../store/journeyStore'
import { createDraftJourneyEntry } from './JourneyDetailPage.helpers'
+import { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail'
+
/** Stable identity for "this journey draws no trip tracks" (#2194). */
const NO_TRACKS: JourneyTrack[] = []
@@ -371,6 +373,17 @@ export function useJourneyDetail() {
return dates
}, [current?.trips])
+ // The route actually recorded over the journal's own dates (#2279), drawn on
+ // the same layer as the GPX tracks because it is the same kind of thing: a
+ // recording, not a line connecting entries. Gated on `show_trip_tracks` for
+ // the same reason that switch exists — it gates the REQUEST, and a map nobody
+ // asked for should not reach across the network for it.
+ const dawarichTrail = useDawarichJournalTrail(tripDates, showTripTracks)
+ const mapTracks = useMemo(
+ () => (dawarichTrail.tracks.length > 0 ? [...tracks, ...dawarichTrail.tracks] : tracks),
+ [tracks, dawarichTrail.tracks],
+ )
+
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
const STUDIO_INSET = 16
@@ -412,7 +425,7 @@ export function useJourneyDetail() {
hideSkeletons, setHideSkeletons,
mapRef, fullMapRef, galleryUploadRef, galleryProviders, setGalleryProviders, galleryBrowseRef,
activeLocationId, handleMarkerClick, handleLocationClick,
- mapEntries, sidebarMapItems, tripDates, isMobile, tracks,
+ mapEntries, sidebarMapItems, tripDates, isMobile, tracks: mapTracks, dawarichTrail,
feedEdge, scrollFeedTo,
loadJourney, updateEntry, deleteEntry, reorderEntries, uploadPhotos, deletePhoto,
}
diff --git a/client/src/pages/settings/useSettings.ts b/client/src/pages/settings/useSettings.ts
index 9f1951f67..d31403e31 100644
--- a/client/src/pages/settings/useSettings.ts
+++ b/client/src/pages/settings/useSettings.ts
@@ -20,7 +20,8 @@ export function useSettings() {
const mcpEnabled = addonEnabled('mcp')
const airtrailEnabled = addonEnabled('airtrail')
const llmEnabled = addonEnabled('llm_parsing')
- const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled
+ const dawarichEnabled = addonEnabled('dawarich')
+ const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled || llmEnabled || dawarichEnabled
const [appVersion, setAppVersion] = useState(null)
const [activeTab, setActiveTab] = useState('display')
diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts
index 6f0b27d97..8a2cb08f0 100644
--- a/client/src/pages/tripPlanner/useTripPlanner.ts
+++ b/client/src/pages/tripPlanner/useTripPlanner.ts
@@ -31,6 +31,7 @@ import { useDayBoundaries } from '../../components/Roadtrip/useDayBoundaries'
import type { DayBoundaryControls } from '../../components/Map/dayBoundaryDrag'
import { dayWindow, roadtripInsertion } from '../../components/Roadtrip/dayWindow'
import { useTripRouteOverview } from '../../components/Map/useTripRouteOverview'
+import { useDawarichTrail } from '../../components/Map/useDawarichTrail'
import { useRoadtripCorridor } from '../../components/Roadtrip/useRoadtripCorridor'
import { useRoadtripVias } from '../../components/Roadtrip/useRoadtripVias'
import { useRefuelSearch } from '../../components/Roadtrip/useRefuelSearch'
@@ -117,7 +118,7 @@ export function useTripPlanner() {
toast.info(t('undo.done', { action: label ?? '' }))
}, [undo, lastActionLabel, toast])
- const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false })
+ const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true, collab: false, roadtrip: false, dawarich: false })
// The values above are an optimistic guess until the addon feed answers. The
// tab guard below waits for this before evicting anything, so a tab we were
// asked to open ('collab' in particular, guessed off) survives the gap.
@@ -164,7 +165,7 @@ export function useTripPlanner() {
addonsApi.enabled().then(data => {
const map: Record = {}
data.addons.forEach(a => { map[a.id] = true })
- setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip })
+ setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab, roadtrip: !!map.roadtrip, dawarich: !!map.dawarich })
if (data.collabFeatures) setCollabFeatures(data.collabFeatures)
}).catch(() => {}).finally(() => setAddonsLoaded(true))
authApi.getAppConfig().then(config => {
@@ -391,6 +392,23 @@ export function useTripPlanner() {
return next
})
}, [tripId])
+ // The recorded route from Dawarich (#2279), per trip and per session for the
+ // same reason as the overview above: it answers "what actually happened on
+ // this trip", which is a question about one trip rather than a preference.
+ const [dawarichTrailShown, setDawarichTrailShown] = useState(
+ () => sessionStorage.getItem(`trip-dawarich-${tripId}`) === '1',
+ )
+ const toggleDawarichTrail = useCallback(() => {
+ setDawarichTrailShown(prev => {
+ const next = !prev
+ sessionStorage.setItem(`trip-dawarich-${tripId}`, next ? '1' : '0')
+ return next
+ })
+ }, [tripId])
+ // Fetched here rather than in MapViewAuto so the desktop and the phone share
+ // one request, and so the pill that toggles it can show why there is no line.
+ const dawarichTrail = useDawarichTrail(tripId, dawarichTrailShown)
+
const [fitKey, setFitKey] = useState(0)
const initialFitTripId = useRef(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
@@ -2177,6 +2195,7 @@ export function useTripPlanner() {
enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations,
roadtripMode, toggleRoadtripMode, roadtripActive, roadtripRoutes, roadtripLineColors, roadtripMapLines, roadtripMapPlaces, collapsedRoadtripDays, toggleRoadtripDay, roadtripCorridor,
overviewShown, toggleOverview, overviewActive, tripOverview,
+ dawarichTrailShown, toggleDawarichTrail, dawarichTrail, dawarichEnabled: !!enabledAddons.dawarich,
followTrack, roadtripViaCounts,
allowedFileTypes, tripMembers, setTripMembers, refreshMembers, loadAccommodations,
TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange,
diff --git a/client/src/repo/dawarichRepo.ts b/client/src/repo/dawarichRepo.ts
new file mode 100644
index 000000000..a2da9b2c7
--- /dev/null
+++ b/client/src/repo/dawarichRepo.ts
@@ -0,0 +1,67 @@
+import {
+ dawarichAtlasSuggestionsSchema,
+ dawarichBucketScanSchema,
+ dawarichSuggestionListSchema,
+ dawarichTrackSchema,
+} from '@trek/shared'
+import { dawarichApi } from '../api/dawarich'
+import { isEffectivelyOffline } from '../sync/networkMode'
+
+/**
+ * Reads from the Dawarich integration.
+ *
+ * **Deliberately online-only, and no Dexie table.** Everything here is a live
+ * read of somebody's location history on a server TREK does not own: a cached
+ * recording is a stale one, and a cached copy of a GPS archive is the thing
+ * this integration exists not to keep. The precedent in this directory is
+ * `roadtripHazardsRepo` / `chargingRepo` / `schoolHolidayCatalogRepo` — same
+ * shape, same reason.
+ *
+ * The offline check is `isEffectivelyOffline()` rather than `navigator.onLine`,
+ * so the manual offline switch counts: someone who turned it on before a flight
+ * has said they do not want requests fired, and firing them anyway would block
+ * the UI on a timeout.
+ *
+ * Responses are parsed against the shared schema rather than cast. A server
+ * that answered something unexpected should fail here, loudly, not three
+ * components later where the cause is invisible.
+ */
+
+/** Thrown before a request is made when the device is offline by choice or by circumstance. */
+export class DawarichOfflineError extends Error {
+ constructor() {
+ super('Dawarich needs a connection')
+ this.name = 'DawarichOfflineError'
+ }
+}
+
+function requireOnline(): void {
+ if (isEffectivelyOffline()) throw new DawarichOfflineError()
+}
+
+export const dawarichRepo = {
+ async suggestions(params?: { tripId?: number; state?: string }) {
+ requireOnline()
+ return dawarichSuggestionListSchema.parse(await dawarichApi.listSuggestions(params))
+ },
+
+ async tripTrack(tripId: number, range?: { from?: string; to?: string }, signal?: AbortSignal) {
+ requireOnline()
+ return dawarichTrackSchema.parse(await dawarichApi.tripTrack(tripId, range, signal))
+ },
+
+ async windowTrack(from: string, to: string, signal?: AbortSignal) {
+ requireOnline()
+ return dawarichTrackSchema.parse(await dawarichApi.windowTrack(from, to, signal))
+ },
+
+ async bucketScan() {
+ requireOnline()
+ return dawarichBucketScanSchema.parse(await dawarichApi.scanBucketList())
+ },
+
+ async atlasSuggestions(from: string, to: string) {
+ requireOnline()
+ return dawarichAtlasSuggestionsSchema.parse(await dawarichApi.atlasSuggestions(from, to))
+ },
+}
diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts
index 83d0c2875..830a8e188 100644
--- a/client/src/store/journeyStore.ts
+++ b/client/src/store/journeyStore.ts
@@ -131,6 +131,15 @@ interface JourneyState {
current: JourneyDetail | null
loading: boolean
notFound: boolean
+ /**
+ * The phone's journey screen is showing its Gallery tab.
+ *
+ * Lives here because the dock's FAB is a sibling of that screen: on the
+ * Gallery the one big action is uploading a photo, not adding an entry, and a
+ * sibling cannot read another component's state any other way.
+ */
+ mobileGalleryOpen: boolean
+ setMobileGalleryOpen: (open: boolean) => void
loadJourneys: () => Promise
loadJourney: (id: number) => Promise
@@ -180,6 +189,8 @@ export const useJourneyStore = create((set, get) => ({
current: null,
loading: false,
notFound: false,
+ mobileGalleryOpen: false,
+ setMobileGalleryOpen: (open: boolean) => set({ mobileGalleryOpen: open }),
loadJourneys: async () => {
set({ loading: true })
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index ddd306e3d..d7c8d8955 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -193,6 +193,13 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner
expandedDayIds: new Set(),
setExpandedDayIds: vi.fn(),
mapPlaces: [],
+ // The recorded-route overlay (#2279): off and empty. MMapArea reads
+ // `dawarichTrail.track` unconditionally, so the shape has to be here even
+ // when the addon is not in play.
+ dawarichEnabled: false,
+ dawarichTrailShown: false,
+ toggleDawarichTrail: vi.fn(),
+ dawarichTrail: { track: null, status: 'idle', reload: vi.fn() },
route: null,
routeSegments: [],
diff --git a/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx b/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx
index 50daf400c..8cc3bc79c 100644
--- a/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx
+++ b/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx
@@ -180,12 +180,19 @@ function buildHook(over: Record = {}): Record
};
}
-function setup(over: Record = {}) {
+function setup(over: Record = {}, entries?: string[]) {
mocks.detail = buildHook(over);
- const view = render();
+ const view = render(, entries ? { initialEntries: entries } : undefined);
return { ...view, hook: mocks.detail };
}
+/**
+ * Opening the upload the way the shell does since the Gallery took over the
+ * dock's FAB: the button asks through `?create=photo`, the screen answers. The
+ * header no longer carries an upload button of its own — one screen, one upload.
+ */
+const UPLOAD_URL = ['/journey/1?create=photo'];
+
const journeyStoreInitial = useJourneyStore.getState();
let uploadGalleryPhotos: ReturnType;
let createEntry: ReturnType;
@@ -355,9 +362,8 @@ describe('MJourneyDetail', () => {
],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
- setup({ view: 'gallery' });
+ setup({ view: 'gallery' }, UPLOAD_URL);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument();
fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
expect(await screen.findByTestId('provider-picker')).toBeInTheDocument();
@@ -370,10 +376,9 @@ describe('MJourneyDetail', () => {
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 });
const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 });
- const { hook } = setup({ view: 'gallery' });
+ const { hook } = setup({ view: 'gallery' }, UPLOAD_URL);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
- fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
+ fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise;
@@ -392,10 +397,9 @@ describe('MJourneyDetail', () => {
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom'));
- setup({ view: 'gallery' });
+ setup({ view: 'gallery' }, UPLOAD_URL);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
- fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
+ fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise;
@@ -509,11 +513,11 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: true });
- const { container } = setup({ view: 'gallery' });
- const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
- const click = vi.spyOn(input, 'click').mockImplementation(() => {});
+ // The upload opens on mount now (the dock asks through the URL), so the spy
+ // goes on the prototype rather than on an input that does not exist yet.
+ const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
+ setup({ view: 'gallery' }, UPLOAD_URL);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice'));
expect(click).toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument());
@@ -524,12 +528,10 @@ describe('MJourneyDetail', () => {
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }],
});
vi.mocked(memoriesApi.status).mockResolvedValue({ connected: false });
- const { container } = setup({ view: 'gallery' });
- const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement;
- const click = vi.spyOn(input, 'click').mockImplementation(() => {});
+ const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
+ setup({ view: 'gallery' }, UPLOAD_URL);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
- expect(click).toHaveBeenCalled();
+ await waitFor(() => expect(click).toHaveBeenCalled());
expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument();
});
@@ -545,7 +547,7 @@ describe('MJourneyDetail', () => {
editingEntry: buildEntry({ id: 4 }),
showSettings: true,
showInvite: true,
- });
+ }, UPLOAD_URL);
(mocks.captured.entrySheet.onClose as () => void)();
expect(hook.setEditingEntry).toHaveBeenCalledWith(null);
@@ -554,8 +556,7 @@ describe('MJourneyDetail', () => {
(mocks.captured.invite.onClose as () => void)();
expect(hook.setShowInvite).toHaveBeenCalledWith(false);
- fireEvent.click(await screen.findByRole('button', { name: 'common.upload' }));
- fireEvent.click(screen.getByText('mobileJourney.browseProvider'));
+ fireEvent.click(await screen.findByText('mobileJourney.browseProvider'));
await screen.findByTestId('provider-picker');
expect([...(mocks.captured.picker.existingAssetIds as Set)]).toEqual(['asset-1']);
diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
index cd52c918a..4406f13a8 100644
--- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
+++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx
@@ -284,7 +284,20 @@ function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) {
}
const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement;
-const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement;
+/**
+ * The date is TREK's own picker (#2067 follow-up): a trigger button whose
+ * accessible name is the date it shows, plus a keyboard button that swaps it for
+ * a DD.MM.YYYY input. `input[type="date"]` is gone — it painted itself from the
+ * OS locale and took no theme.
+ */
+const dateTrigger = () =>
+ document.querySelector('[aria-haspopup="dialog"]') as HTMLButtonElement;
+
+/** What the trigger shows, as the picker formats it: "18 Mar 2026". */
+const shownDate = (iso: string) =>
+ new Date(iso + 'T00:00:00Z').toLocaleDateString('en', {
+ day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC',
+ });
// The time field is CustomTimePicker's text input, not a native time input — a
// native one paints 12h/24h from the browser locale and ignored the setting (#2067).
const timeField = () => document.querySelector('input[placeholder="00:00"], input[placeholder="2:30 PM"]') as HTMLInputElement;
@@ -330,7 +343,7 @@ describe('MJourneyEntrySheet full editor', () => {
expect(screen.getByText('Weather')).toBeInTheDocument();
expect(screen.getByText('Tags')).toBeInTheDocument();
// An empty entry_date falls back to today (the LOCAL date), an empty gallery locks the picker.
- expect(dateField().value).toBe(localIsoDate());
+ expect(dateTrigger()).toHaveAccessibleName(shownDate(localIsoDate()));
expect(timeField()).toHaveValue('');
expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled();
@@ -372,7 +385,14 @@ describe('MJourneyEntrySheet full editor', () => {
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome');
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day');
- fireEvent.change(dateField(), { target: { value: '2026-03-18' } });
+ // Through the picker's manual entry — the calendar's own path would depend
+ // on which month it opens on.
+ // The keyboard button and the input it opens share a name — they never
+ // exist at the same time, so the same query reaches first one, then the other.
+ fireEvent.click(screen.getByLabelText('Enter date manually'));
+ const manual = screen.getByLabelText('Enter date manually');
+ fireEvent.change(manual, { target: { value: '18.03.2026' } });
+ fireEvent.keyDown(manual, { key: 'Enter' });
fireEvent.change(timeField(), { target: { value: '18:45' } });
const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ });
@@ -905,7 +925,7 @@ describe('MJourneyEntrySheet read-only', () => {
expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly');
expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly');
- expect(dateField()).toBeDisabled();
+ expect(dateTrigger()).toBeDisabled();
expect(timeField()).toBeDisabled();
expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled();
expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled();
diff --git a/server/scripts/migrate-encryption.ts b/server/scripts/migrate-encryption.ts
index 4c13c431b..4b4e2559e 100644
--- a/server/scripts/migrate-encryption.ts
+++ b/server/scripts/migrate-encryption.ts
@@ -530,6 +530,22 @@ async function main() {
}
}
+ // --- dawarich_connections: api_key ---
+ // Its own table rather than a users column, so it needs its own block. The
+ // table arrived with a later migration, hence the existence check: an older
+ // database simply has nothing to rotate here.
+ if (tableExists('dawarich_connections')) {
+ const connections = db
+ .prepare('SELECT user_id, api_key FROM dawarich_connections WHERE api_key IS NOT NULL')
+ .all() as { user_id: number; api_key: string }[];
+ for (const row of connections) {
+ const newVal = migrateApiKeyValue(row.api_key, `dawarich_connections[${row.user_id}].api_key`);
+ if (newVal !== null) {
+ db.prepare('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?').run(newVal, row.user_id);
+ }
+ }
+ }
+
// --- trek_photos: passphrase ---
const photos = db.prepare('SELECT id, passphrase FROM trek_photos WHERE passphrase IS NOT NULL').all() as { id: number; passphrase: string }[];
for (const row of photos) {
diff --git a/server/src/addons.ts b/server/src/addons.ts
index 2f72c50fa..14c897f8d 100644
--- a/server/src/addons.ts
+++ b/server/src/addons.ts
@@ -8,6 +8,7 @@ export const ADDON_IDS = {
COLLAB: 'collab',
JOURNEY: 'journey',
AIRTRAIL: 'airtrail',
+ DAWARICH: 'dawarich',
LLM_PARSING: 'llm_parsing',
COLLECTIONS: 'collections',
ROADTRIP: 'roadtrip',
@@ -40,6 +41,7 @@ export const MCP_GATED_ADDON_IDS: readonly AddonId[] = [
ADDON_IDS.BUDGET,
ADDON_IDS.COLLAB,
ADDON_IDS.COLLECTIONS,
+ ADDON_IDS.DAWARICH,
ADDON_IDS.JOURNEY,
ADDON_IDS.PACKING,
ADDON_IDS.ROADTRIP,
diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts
index 0ad56fec7..60e267bb1 100644
--- a/server/src/db/migrations.ts
+++ b/server/src/db/migrations.ts
@@ -4620,6 +4620,117 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT');
}
},
+ // Dawarich connection (#2279). Its own table rather than more `users` columns,
+ // which is where the AirTrail connection lives: that was the right call while
+ // AirTrail was the only integration of this shape, and this is the second. It
+ // also carries sync state (cursor, last error, probed capabilities) that has no
+ // business sitting on the identity row. Credentials for a personal location
+ // archive stay strictly apart from anything shared on a trip.
+ () => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS dawarich_connections (
+ user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ url TEXT,
+ api_key TEXT,
+ allow_insecure_tls INTEGER NOT NULL DEFAULT 0,
+ sync_enabled INTEGER NOT NULL DEFAULT 1,
+ last_sync_at TEXT,
+ last_sync_state TEXT NOT NULL DEFAULT 'never',
+ last_sync_error TEXT,
+ capabilities TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )
+ `);
+ },
+ // Reviewed visit suggestions (#2279). One row per (user, Dawarich visit id) —
+ // the UNIQUE is what makes a repeated sync idempotent instead of duplicating
+ // everything it already imported.
+ //
+ // `source_hash` exists because a Dawarich visit carries no `updated_at`: a
+ // rename or a re-detection is only visible as a different hash of the fields
+ // TREK shows. `source_missing_at` exists because deleting a visit removes it
+ // from the API rather than tombstoning it, so absence from a full re-read of
+ // the same window is the only signal — and it is recorded, not acted on,
+ // because an entry the user already accepted and edited is theirs now.
+ () => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS dawarich_visit_suggestions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ source_visit_id TEXT NOT NULL,
+ trip_id INTEGER REFERENCES trips(id) ON DELETE SET NULL,
+ name TEXT NOT NULL,
+ lat REAL,
+ lng REAL,
+ started_at TEXT NOT NULL,
+ ended_at TEXT NOT NULL,
+ duration_minutes INTEGER NOT NULL DEFAULT 0,
+ local_date TEXT NOT NULL,
+ source_status TEXT NOT NULL DEFAULT 'suggested',
+ confidence REAL,
+ confidence_band TEXT,
+ country_code TEXT,
+ state TEXT NOT NULL DEFAULT 'new',
+ target TEXT,
+ accepted_place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
+ accepted_journal_entry_id INTEGER,
+ accepted_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL,
+ matched_bucket_list_item_id INTEGER REFERENCES bucket_list(id) ON DELETE SET NULL,
+ source_hash TEXT NOT NULL,
+ accepted_hash TEXT,
+ source_missing_at TEXT,
+ first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ last_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(user_id, source_visit_id)
+ )
+ `);
+ db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_user_state ON dawarich_visit_suggestions(user_id, state)');
+ db.exec('CREATE INDEX IF NOT EXISTS idx_dawarich_suggestions_trip ON dawarich_visit_suggestions(trip_id)');
+ },
+ // Bucket-list entries can now be ticked off (#2279). `visited_source` records
+ // who decided — a hand-ticked wish and one confirmed from a recording read the
+ // same on the map otherwise, and re-running a scan must not touch the first.
+ () => {
+ const hasVisitedAt = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_at'").get();
+ if (!hasVisitedAt) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_at TEXT');
+ const hasVisitedSource = db.prepare("SELECT 1 FROM pragma_table_info('bucket_list') WHERE name = 'visited_source'").get();
+ if (!hasVisitedSource) db.exec('ALTER TABLE bucket_list ADD COLUMN visited_source TEXT');
+ },
+ // Where an Atlas country came from (#2279). Everything that exists today was
+ // marked by hand, so 'manual' is the correct backfill rather than a guess;
+ // countries confirmed out of a recording are written as 'dawarich' and the
+ // Atlas can say so. Unmarking still deletes the row either way.
+ () => {
+ const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('visited_countries') WHERE name = 'source'").get();
+ if (!hasSource) db.exec("ALTER TABLE visited_countries ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'");
+ },
+ // Per-key read scopes for /api/v1 (#2279). The `include` parameter picks which
+ // sections a *response* carries; it has never restricted what a key may read,
+ // and handing an integration a key that reads every trip because it wanted day
+ // notes is the thing that needed fixing.
+ //
+ // `scope_mode` is an explicit flag rather than "NULL means everything": a
+ // sentinel here would mean a bug that drops the scopes column silently grants
+ // full access. Every existing key is 'all', so nothing that works today stops
+ // working — the restriction is opt-in at mint time.
+ () => {
+ const hasMode = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'scope_mode'").get();
+ if (!hasMode) db.exec("ALTER TABLE mcp_tokens ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'all'");
+ const hasScopes = db.prepare("SELECT 1 FROM pragma_table_info('mcp_tokens') WHERE name = 'api_scopes'").get();
+ if (!hasScopes) db.exec('ALTER TABLE mcp_tokens ADD COLUMN api_scopes TEXT');
+ },
+ // Where a place came from, when it did not come from a person typing it
+ // (#2279). Only 'dawarich' writes it today; NULL is every place anyone has
+ // ever added by hand, which is what it should stay.
+ //
+ // A column rather than a lookup through dawarich_visit_suggestions: the mark
+ // has to survive the suggestion being deleted, and a place list would
+ // otherwise join an integration's table to render a name.
+ () => {
+ const hasSource = db.prepare("SELECT 1 FROM pragma_table_info('places') WHERE name = 'source'").get();
+ if (!hasSource) db.exec('ALTER TABLE places ADD COLUMN source TEXT');
+ },
];
if (currentVersion < migrations.length) {
diff --git a/server/src/db/seeds.ts b/server/src/db/seeds.ts
index 8ad4ff046..9ef273e78 100644
--- a/server/src/db/seeds.ts
+++ b/server/src/db/seeds.ts
@@ -123,6 +123,7 @@ function seedAddons(db: Database.Database): void {
{ id: 'roadtrip', name: 'Road trip', description: 'Drives with stops along the route, driving times, and arrival times that update themselves', type: 'trip', icon: 'Route', enabled: 0, sort_order: 7 },
{ 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 AirTrail instance', type: 'integration', icon: 'Plane', enabled: 0, sort_order: 14 },
+ { id: 'dawarich', name: 'Dawarich', description: 'Read visits and recorded routes from your Dawarich instance — suggested journal entries, places and countries you confirm yourself', type: 'integration', icon: 'Dawarich', enabled: 0, sort_order: 17 },
{ id: 'llm_parsing', name: 'AI Parsing', description: 'LLM fallback for booking imports kitinerary cannot read', type: 'integration', icon: 'Sparkles', enabled: 0, sort_order: 15 },
{ id: 'collections', name: 'Collections', description: 'Personal place library — save places across trips into named lists, copy into any trip, share with others', type: 'global', icon: 'Bookmark', enabled: 0, sort_order: 16 },
];
diff --git a/server/src/nest/admin/admin.service.ts b/server/src/nest/admin/admin.service.ts
index 5e3677f7e..349337bee 100644
--- a/server/src/nest/admin/admin.service.ts
+++ b/server/src/nest/admin/admin.service.ts
@@ -548,13 +548,15 @@ export class AdminService {
// to the operator, so there is no instance decision left to make — not even
// whether it runs.
//
- // AirTrail: the addon exists to reach a server the admin runs themselves,
- // and a managed instance has no route to one.
+ // AirTrail and Dawarich: both addons exist to reach a server the admin
+ // runs themselves, and a managed instance has no route to one.
.filter(
(a) =>
!(
readEnv().managed.enabled &&
- (a.id === ADDON_IDS.LLM_PARSING || a.id === ADDON_IDS.AIRTRAIL)
+ (a.id === ADDON_IDS.LLM_PARSING ||
+ a.id === ADDON_IDS.AIRTRAIL ||
+ a.id === ADDON_IDS.DAWARICH)
),
);
const providers = this.db.all<{
diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts
index d9a77caa3..966e2bba4 100644
--- a/server/src/nest/app.module.ts
+++ b/server/src/nest/app.module.ts
@@ -44,6 +44,7 @@ import { FilesModule } from './files/files.module';
import { PhotosModule } from './photos/photos.module';
import { MemoriesModule } from './memories/memories.module';
import { AirtrailModule } from './integrations/airtrail.module';
+import { DawarichModule } from './integrations/dawarich.module';
import { JourneyModule } from './journey/journey.module';
import { CollectionsModule } from './collections/collections.module';
import { ShareModule } from './share/share.module';
@@ -80,7 +81,7 @@ import { RealtimeGatewayModule } from './realtime/realtime-gateway.module';
* migrated.
*/
@Module({
- imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
+ imports: [AppConfigModule, DatabaseModule, RealtimeModule, RealtimeGatewayModule, SchedulingModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }), HealthModule, PlatformModule, McpTransportModule, WeatherModule, PublicApiModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, GeoModule, MapsModule, PlaceEnrichmentModule, PlaceShadowModule, RouteUsageModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, SchoolHolidaysModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, DayNotesModule, AccommodationsModule, AssignmentsModule, PlacesModule, RoadtripModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, DawarichModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, StorageModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, AuditModule, PermissionsModule, PluginsModule, BookingImportModule, ReservationImportModule, LlmParseModule, ManagedExtModule],
providers: [
// Default-deny: a route is authenticated unless it carries @Public() or
// @OptionalAuth(), or declares its own @UseGuards chain. Protection used to
diff --git a/server/src/nest/atlas/atlas.service.ts b/server/src/nest/atlas/atlas.service.ts
index 018fc1888..94fe8c18d 100644
--- a/server/src/nest/atlas/atlas.service.ts
+++ b/server/src/nest/atlas/atlas.service.ts
@@ -82,6 +82,19 @@ function blankToNull(value: string | null | undefined): string | null {
return value === undefined || value === null || value === '' ? null : value;
}
+/**
+ * Who ticked a country off, for the detail sheet.
+ *
+ * Null when there is no row at all — a country derived from a trip's places was
+ * never marked by anyone, and reporting 'manual' for it would claim a decision
+ * nobody made. A row written before the column existed defaults to 'manual',
+ * which is exactly what it was.
+ */
+function markedSource(row: { source?: string | null } | undefined): string | null {
+ if (!row) return null;
+ return row.source || 'manual';
+}
+
/**
* DI-native atlas domain service — the legacy services/atlasService SQL
* (stats aggregation, visited countries/regions with the #1490 tombstones,
@@ -469,10 +482,17 @@ export class AtlasService {
if (tripIds.length === 0) {
// Post-fold quirk fix: the legacy early return hardcoded manually_marked
// false, so a trip-less user's manually marked country read as unmarked.
- const marked = !!this.db
- .prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
- .get(userId, code);
- return { places: [], trips: [], manually_marked: marked, status: marked ? 'visited' : 'idea' };
+ const row = this.db
+ .prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?')
+ .get(userId, code) as { source?: string | null } | undefined;
+ const marked = !!row;
+ return {
+ places: [],
+ trips: [],
+ manually_marked: marked,
+ marked_source: markedSource(row),
+ status: marked ? 'visited' : 'idea',
+ };
}
const places = this.getPlacesForTrips(tripIds);
@@ -506,9 +526,10 @@ export class AtlasService {
.filter((t) => matchingTripIds.has(t.id))
.map((t) => ({ id: t.id, title: t.title, start_date: t.start_date, end_date: t.end_date }));
- const isManuallyMarked = !!this.db
- .prepare('SELECT 1 FROM visited_countries WHERE user_id = ? AND country_code = ?')
- .get(userId, code);
+ const markRow = this.db
+ .prepare('SELECT source FROM visited_countries WHERE user_id = ? AND country_code = ?')
+ .get(userId, code) as { source?: string | null } | undefined;
+ const isManuallyMarked = !!markRow;
// Take the status from the same trip classification stats() uses rather than deriving
// it again here — the detail sheet and the map must agree on what this country is.
@@ -517,15 +538,24 @@ export class AtlasService {
for (const id of matchingTripIds) status = strongerVisitStatus(status, tripStatus.get(id) ?? 'idea');
if (isManuallyMarked) status = 'visited';
- return { places: matchingPlaces, trips: matchingTrips, manually_marked: isManuallyMarked, status };
+ return {
+ places: matchingPlaces,
+ trips: matchingTrips,
+ manually_marked: isManuallyMarked,
+ // Where the tick came from, so the sheet can say "confirmed from your
+ // Dawarich recordings" instead of implying the user typed it in. Null when
+ // the country is only derived from trips and carries no row at all.
+ marked_source: markedSource(markRow),
+ status,
+ };
}
// ── Mark / unmark country ─────────────────────────────────────────────────
- listVisitedCountries(userId: number): { country_code: string; created_at: string }[] {
+ listVisitedCountries(userId: number): { country_code: string; created_at: string; source: string }[] {
return this.db
- .prepare('SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC')
- .all(userId) as { country_code: string; created_at: string }[];
+ .prepare('SELECT country_code, created_at, source FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC')
+ .all(userId) as { country_code: string; created_at: string; source: string }[];
}
/** Countries the user explicitly removed, which stats() must not re-derive (#1490). */
@@ -536,11 +566,24 @@ export class AtlasService {
return new Set(rows.map((r) => r.country_code));
}
- markCountry(userId: number, code: string): void {
- this.db.transaction(() => {
- this.db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code);
+ /**
+ * `source` records who decided. It defaults to 'manual' because that is what
+ * every mark was until an integration could make one, and `INSERT OR IGNORE`
+ * means a country already ticked by hand keeps that provenance — confirming
+ * it again from a recording does not relabel somebody's own work as imported.
+ */
+ /**
+ * Returns whether this actually added the country, so a caller reporting
+ * "3 countries added" is not counting the ones that were already there.
+ */
+ markCountry(userId: number, code: string, source: 'manual' | 'dawarich' = 'manual'): boolean {
+ return this.db.transaction(() => {
+ const inserted = this.db
+ .prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code, source) VALUES (?, ?, ?)')
+ .run(userId, code, source).changes > 0;
// Marking it visited again lifts a previous removal.
this.db.prepare('DELETE FROM hidden_countries WHERE user_id = ? AND country_code = ?').run(userId, code);
+ return inserted;
});
}
diff --git a/server/src/nest/atlas/public-stats.controller.ts b/server/src/nest/atlas/public-stats.controller.ts
index ea704a883..d4317d8ee 100644
--- a/server/src/nest/atlas/public-stats.controller.ts
+++ b/server/src/nest/atlas/public-stats.controller.ts
@@ -3,7 +3,7 @@ import type { Request } from 'express';
import type { PublicApiStats } from '@trek/shared';
import { AtlasService } from './atlas.service';
import { ApiTokenGuard } from '../public-api/api-token.guard';
-import { enforcePublicApiRateLimit, requireUserId } from '../public-api/public-api-request';
+import { enforcePublicApiRateLimit, requireScope, requireUserId } from '../public-api/public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
@@ -30,9 +30,12 @@ import { RateLimitService } from '../common/rate-limit.service';
* instead. TravelStatsController next door already does exactly this for
* `/api/auth/travel-stats`, and for the same reason.
*
- * What crosses the boundary is only the request handling: the guard and the shared
- * rate-limit bucket, so an integration polling `/trips` and `/stats` spends one
- * budget rather than two.
+ * What crosses the boundary is only the request handling: the guard, the shared
+ * rate-limit bucket (so an integration polling `/trips` and `/stats` spends one
+ * budget rather than two) and the key's read scopes. The scope check is not
+ * optional here: this route aggregates trips, countries, cities and flown
+ * distance, which is precisely the reach a narrowed key exists to withhold, and
+ * enforcing scopes only in public-api/ would leave the widest answer open.
*/
@Controller('api/v1')
@UseGuards(ApiTokenGuard)
@@ -45,6 +48,7 @@ export class PublicStatsController {
@Get('stats')
stats(@Req() req: Request): PublicApiStats {
enforcePublicApiRateLimit(this.rl, req);
+ requireScope(req, 'stats');
const userId = requireUserId(req);
const travel = this.atlas.getTravelStats(userId);
diff --git a/server/src/nest/auth/auth.controller.ts b/server/src/nest/auth/auth.controller.ts
index 0e1967f46..f52b214ee 100644
--- a/server/src/nest/auth/auth.controller.ts
+++ b/server/src/nest/auth/auth.controller.ts
@@ -33,6 +33,7 @@ import {
MfaEnableDto,
MfaDisableDto,
McpTokenCreateDto,
+ ApiTokenCreateDto,
ResourceTokenDto,
} from './auth.dto';
import { RateLimitService } from '../common/rate-limit.service';
@@ -340,9 +341,12 @@ export class AuthController {
@Post('api-tokens')
@HttpCode(201)
- createApiToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
+ createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
- const result = this.tokens.createApiToken(user.id, body.name);
+ // No `scopes` means the key reads everything, which is what every key minted
+ // before this field existed does. Narrowing stays opt-in so the change
+ // cannot break an integration that is already running.
+ const result = this.tokens.createApiToken(user.id, body.name, body.scopes);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
diff --git a/server/src/nest/auth/auth.dto.ts b/server/src/nest/auth/auth.dto.ts
index 2f806c855..876af5111 100644
--- a/server/src/nest/auth/auth.dto.ts
+++ b/server/src/nest/auth/auth.dto.ts
@@ -9,6 +9,7 @@ import {
mfaEnableRequestSchema,
mfaDisableRequestSchema,
mcpTokenCreateRequestSchema,
+ apiTokenCreateRequestSchema,
mapsKeyUpdateRequestSchema,
apiKeysUpdateRequestSchema,
settingsUpdateRequestSchema,
@@ -38,6 +39,12 @@ export class MfaVerifyLoginDto extends createZodDto(mfaVerifyLoginRequestSchema)
export class MfaEnableDto extends createZodDto(mfaEnableRequestSchema) {}
export class MfaDisableDto extends createZodDto(mfaDisableRequestSchema) {}
export class McpTokenCreateDto extends createZodDto(mcpTokenCreateRequestSchema) {}
+/**
+ * Its own DTO rather than a field added to the one above: both create routes
+ * share that schema, and widening it would make POST /api/auth/mcp-tokens
+ * quietly accept read scopes that an MCP token has no way to honour.
+ */
+export class ApiTokenCreateDto extends createZodDto(apiTokenCreateRequestSchema) {}
export class MapsKeyUpdateDto extends createZodDto(mapsKeyUpdateRequestSchema) {}
export class ApiKeysUpdateDto extends createZodDto(apiKeysUpdateRequestSchema) {}
export class SettingsUpdateDto extends createZodDto(settingsUpdateRequestSchema) {}
diff --git a/server/src/nest/integrations/dawarich-suggestions.service.ts b/server/src/nest/integrations/dawarich-suggestions.service.ts
new file mode 100644
index 000000000..57b50fdc8
--- /dev/null
+++ b/server/src/nest/integrations/dawarich-suggestions.service.ts
@@ -0,0 +1,734 @@
+import { Injectable } from '@nestjs/common';
+import {
+ DAWARICH_BUCKET_MATCH_MIN_MINUTES,
+ DAWARICH_BUCKET_MATCH_RADIUS_M,
+ DAWARICH_BUCKET_SCAN_LIMIT,
+ type DawarichAccept,
+ type DawarichAcceptResult,
+ type DawarichAtlasCountry,
+ type DawarichAtlasSuggestions,
+ type DawarichBucketMatch,
+ type DawarichBucketScan,
+ type DawarichSuggestion,
+ type DawarichSuggestionList,
+} from '@trek/shared';
+import { DatabaseService } from '../database/database.service';
+import { AtlasService } from '../atlas/atlas.service';
+import { PlacesService } from '../places/places.service';
+import { AssignmentsService } from '../assignments/assignments.service';
+import { PermissionsService } from '../permissions/permissions.service';
+import { JourneyDomainService } from '../journey/journey-domain.service';
+import { NAME_TO_CODE } from '../atlas/atlas-geo';
+import { DawarichClient, type DawarichCreds } from './dawarich.client';
+import { DawarichService } from './dawarich.service';
+import { minutesBetween, toNumber } from './dawarich.helpers';
+
+/**
+ * What a user does with the stays a sync brought in.
+ *
+ * Everything here is an explicit act by the person the suggestion belongs to.
+ * That is the whole point of the split: the sync may look at a shared trip, but
+ * only this service is ever allowed to write to one, and only because somebody
+ * pressed something.
+ *
+ * Three destinations, deliberately not one:
+ * - **a place on the trip**, for a stay that was a stop worth planning around;
+ * - **a journal entry**, for one that was a moment worth writing about;
+ * - **a bucket-list tick**, for one that fulfilled a wish.
+ * They are separate because accepting into the wrong one is a mess to undo, and
+ * a single "import" button would have to guess which was meant.
+ *
+ * The trip UI offers the first two. A wish belongs to the Atlas — it is reached
+ * once, and it is confirmed there, against the whole wishlist at once — so the
+ * third is reached from the Atlas and from MCP, not from a row in a trip's rail.
+ */
+@Injectable()
+export class DawarichSuggestionsService {
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly dawarich: DawarichService,
+ private readonly client: DawarichClient,
+ private readonly atlas: AtlasService,
+ private readonly places: PlacesService,
+ private readonly assignments: AssignmentsService,
+ private readonly permissions: PermissionsService,
+ private readonly journey: JourneyDomainService,
+ ) {}
+
+ /**
+ * The review list, newest stay first.
+ *
+ * Scoped by `user_id` in SQL rather than filtered afterwards: these rows are
+ * someone's location history, and a forgotten filter over a wider read is how
+ * that leaks.
+ */
+ list(userId: number, filter: { tripId?: number; state?: string }): DawarichSuggestionList {
+ this.reopenOrphaned(userId);
+
+ const clauses = ['s.user_id = ?'];
+ const params: unknown[] = [userId];
+ if (filter.tripId !== undefined) {
+ clauses.push('s.trip_id = ?');
+ params.push(filter.tripId);
+ }
+ if (filter.state) {
+ clauses.push('s.state = ?');
+ params.push(filter.state);
+ }
+
+ const rows = this.db.all(
+ `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name
+ FROM dawarich_visit_suggestions s
+ LEFT JOIN trips t ON t.id = s.trip_id
+ LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id
+ WHERE ${clauses.join(' AND ')}
+ ORDER BY s.started_at DESC, s.id DESC`,
+ ...params,
+ );
+
+ const connection = this.dawarich.getConnection(userId);
+ return {
+ suggestions: rows.map(toWire),
+ connected: connection.connected,
+ lastSyncAt: connection.lastSyncAt,
+ lastSyncState: connection.lastSyncState,
+ lastSyncError: connection.lastSyncError,
+ };
+ }
+
+ /**
+ * Put back into review every acceptance whose result no longer exists.
+ *
+ * Deleting the place an acceptance created is how somebody says "not that
+ * one" after the fact, and the stay then has nothing to show for itself: it
+ * is unhandled again, and leaving it in the handled list is a dead end — the
+ * row points at a place that is gone and accepting it again is refused as a
+ * duplicate.
+ *
+ * The place and bucket columns are `ON DELETE SET NULL`, so a NULL beside a
+ * target that names them IS the deletion. Journey entries are written by
+ * another domain without a foreign key, so that one is asked directly.
+ *
+ * A write on a read path, deliberately: the alternative is computing the state
+ * at read time, which would make the list say "back in review" while `accept`
+ * still refused the row as already accepted.
+ */
+ private reopenOrphaned(userId: number): void {
+ this.db.run(
+ `UPDATE dawarich_visit_suggestions
+ SET state = 'new', target = NULL, accepted_hash = NULL,
+ accepted_place_id = NULL, accepted_journal_entry_id = NULL,
+ accepted_bucket_list_item_id = NULL
+ WHERE user_id = ? AND state = 'accepted'
+ AND (
+ (target = 'place' AND accepted_place_id IS NULL)
+ OR (target = 'bucket_list' AND accepted_bucket_list_item_id IS NULL)
+ OR (target = 'journal' AND (
+ accepted_journal_entry_id IS NULL
+ OR NOT EXISTS (
+ SELECT 1 FROM journey_entries je WHERE je.id = accepted_journal_entry_id
+ )
+ ))
+ )`,
+ userId,
+ );
+ }
+
+ getOne(userId: number, id: number): DawarichSuggestion | null {
+ const row = this.db.get(
+ `SELECT s.*, t.title AS trip_title, b.name AS matched_bucket_name
+ FROM dawarich_visit_suggestions s
+ LEFT JOIN trips t ON t.id = s.trip_id
+ LEFT JOIN bucket_list b ON b.id = s.matched_bucket_list_item_id
+ WHERE s.id = ? AND s.user_id = ?`,
+ id,
+ userId,
+ );
+ return row ? toWire(row) : null;
+ }
+
+ /**
+ * Put a suggestion back into review, or take it out of the list.
+ *
+ * Reversible on purpose: dismissing is the cheap action people take to clear
+ * a backlog, and the one they regret. Restoring an *accepted* suggestion is
+ * not offered — the place or entry it produced is a separate thing now, and
+ * "undo" would have to guess whether to delete it.
+ */
+ setState(userId: number, id: number, state: 'new' | 'dismissed'): DawarichSuggestion | null {
+ const row = this.db.get<{ id: number; state: string }>(
+ 'SELECT id, state FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
+ id,
+ userId,
+ );
+ if (!row) return null;
+ if (row.state === 'accepted') return this.getOne(userId, id);
+
+ this.db.run('UPDATE dawarich_visit_suggestions SET state = ? WHERE id = ?', state, id);
+ return this.getOne(userId, id);
+ }
+
+ /**
+ * Accept a suggestion into TREK.
+ *
+ * Throws `AcceptError` rather than an HttpException so the controller, the
+ * MCP tool and any future caller each shape the failure their own way — the
+ * atlas domain's `BucketItemExistsError` is the precedent.
+ */
+ accept(userId: number, id: number, body: DawarichAccept, sid?: string): DawarichAcceptResult {
+ const row = this.db.get(
+ 'SELECT * FROM dawarich_visit_suggestions WHERE id = ? AND user_id = ?',
+ id,
+ userId,
+ );
+ if (!row) throw new AcceptError('not_found', 'Suggestion not found', 404);
+ if (row.state === 'accepted') throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409);
+
+ switch (body.target) {
+ case 'place':
+ return this.acceptAsPlace(userId, row, body, sid);
+ case 'journal':
+ return this.acceptAsJournalEntry(userId, row, body, sid);
+ case 'bucket_list':
+ return this.acceptAsBucketTick(userId, row, body);
+ }
+ }
+
+ /**
+ * A stay becomes a place on a trip, optionally pinned to one of its days.
+ *
+ * Every referenced id is verified against the trip before anything is
+ * written: permission to touch the trip is not permission to attach a place
+ * to somebody else's day.
+ */
+ private acceptAsPlace(
+ userId: number,
+ row: SuggestionRow,
+ body: DawarichAccept,
+ sid?: string,
+ ): DawarichAcceptResult {
+ const tripId = body.tripId ?? row.trip_id;
+ if (!tripId) throw new AcceptError('trip_required', 'A trip is required to create a place', 400);
+
+ const access = this.db.canAccessTrip(tripId, userId);
+ if (!access) throw new AcceptError('not_found', 'Trip not found', 404);
+
+ // Being on the roster is not permission to write to it. Every other way of
+ // putting a place on a trip asks `place_edit` (and `day_edit` for the
+ // assignment), and on an instance where an admin narrowed either to the
+ // owner, a member accepting a stay must be refused exactly as they would be
+ // in the planner. Asked here rather than in the controller so the MCP tool
+ // cannot take a different route to the same write.
+ this.requirePermission('place_edit', access.user_id, userId);
+
+ if (body.dayId !== undefined) {
+ this.requirePermission('day_edit', access.user_id, userId);
+ if (!this.assignments.dayExists(body.dayId, tripId)) {
+ throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400);
+ }
+ }
+
+ const lat = body.lat ?? row.lat ?? undefined;
+ const lng = body.lng ?? row.lng ?? undefined;
+
+ // One transaction for the four writes an acceptance makes. Half of it —
+ // a place with no assignment, or a place nobody recorded as accepted — is
+ // worse than none: the stay would come back as unhandled while the place it
+ // already produced sat on the trip.
+ const { created, placeId, assignment } = this.db.transaction(() => {
+ const place = this.places.create(String(tripId), {
+ name: body.name?.trim() || row.name,
+ lat,
+ lng,
+ notes: body.notes ?? null,
+ place_time: body.time ?? timeOf(row.started_at),
+ end_time: body.endTime ?? timeOf(row.ended_at),
+ duration_minutes: row.duration_minutes > 0 ? row.duration_minutes : undefined,
+ } as Parameters[1]);
+
+ const id = Number((place as { id: number }).id);
+ // Stamped here rather than passed through PlacesService.create: `source`
+ // is not part of the create contract and should not become something a
+ // request body can set — a place claims to come from a recording only
+ // because this path made it.
+ this.db.run("UPDATE places SET source = 'dawarich' WHERE id = ?", id);
+
+ const day = body.dayId !== undefined ? this.assignments.createAssignment(body.dayId, id, null) : undefined;
+ this.markAccepted(row.id, 'place', { placeId: id });
+ // Re-read, so what goes out on the wire carries the source: everyone else
+ // on the trip should see the Dawarich mark on it too, not only the person
+ // who accepted it.
+ return { created: this.db.getPlaceWithTags(id) ?? place, placeId: id, assignment: day };
+ });
+
+ // Outside the transaction, because both reach past the database: a
+ // broadcast of a write that then rolls back is a lie every other client
+ // believes, and the journey mirror writes on its own.
+ //
+ // The same two side effects the places controller performs, because this IS
+ // a place somebody added to the trip. Skipping them is why an accepted stay
+ // used to need a page reload to show up.
+ this.places.broadcast(String(tripId), 'place:created', { place: created }, sid);
+ this.places.onCreated(String(tripId), placeId);
+ if (assignment) {
+ this.assignments.broadcast(String(tripId), 'assignment:created', { assignment }, sid);
+ }
+
+ return {
+ suggestion: this.getOne(userId, row.id)!,
+ createdPlaceId: placeId,
+ createdJournalEntryId: null,
+ bucketListItemId: null,
+ };
+ }
+
+ /**
+ * A stay becomes a dated journal entry.
+ *
+ * The date comes from the stay's own local date unless corrected, and the
+ * arrival time fills the entry's time — the two fields a person would
+ * otherwise copy off the suggestion by hand.
+ */
+ private acceptAsJournalEntry(
+ userId: number,
+ row: SuggestionRow,
+ body: DawarichAccept,
+ sid?: string,
+ ): DawarichAcceptResult {
+ if (body.journalId === undefined) {
+ throw new AcceptError('journal_required', 'A journey is required to create an entry', 400);
+ }
+ // canEdit is the journey domain's own answer, and createEntry asks it again.
+ // Asking here too is what turns "silently did nothing" into a 403.
+ if (!this.journey.canEdit(body.journalId, userId)) {
+ throw new AcceptError('journal_forbidden', 'Journey not found', 404);
+ }
+
+ // The entry and the record that it was written commit together. Apart, a
+ // failure between them leaves an entry in the journal with the stay still
+ // listed as unhandled — and the next acceptance writes a second copy of it.
+ //
+ // The journey domain broadcasts the new entry from inside `createEntry`, so
+ // that one notification can still outrun a rollback. Moving it out means
+ // changing that domain's contract, which this integration has no business
+ // doing; the write itself is what had to stop being two.
+ const entry = this.db.transaction(() => {
+ const created = this.journey.createEntry(
+ body.journalId,
+ userId,
+ {
+ type: 'entry',
+ title: body.name?.trim() || row.name,
+ story: body.notes ?? undefined,
+ entry_date: body.date ?? row.local_date,
+ entry_time: body.time ?? timeOf(row.started_at) ?? undefined,
+ location_name: body.name?.trim() || row.name,
+ location_lat: body.lat ?? row.lat ?? undefined,
+ location_lng: body.lng ?? row.lng ?? undefined,
+ },
+ sid,
+ );
+ if (!created) throw new AcceptError('journal_forbidden', 'Journey not found', 404);
+ this.markAccepted(row.id, 'journal', { journalEntryId: created.id });
+ return created;
+ });
+ return {
+ suggestion: this.getOne(userId, row.id)!,
+ createdPlaceId: null,
+ createdJournalEntryId: entry.id,
+ bucketListItemId: null,
+ };
+ }
+
+ /** A stay ticks off a wish. The wish must be the caller's own. */
+ private acceptAsBucketTick(userId: number, row: SuggestionRow, body: DawarichAccept): DawarichAcceptResult {
+ const itemId = body.bucketListItemId ?? row.matched_bucket_list_item_id;
+ if (!itemId) {
+ throw new AcceptError('bucket_required', 'A bucket-list entry is required', 400);
+ }
+ const item = this.db.get<{ id: number }>(
+ 'SELECT id FROM bucket_list WHERE id = ? AND user_id = ?',
+ itemId,
+ userId,
+ );
+ if (!item) throw new AcceptError('not_found', 'Bucket-list entry not found', 404);
+
+ this.db.transaction(() => {
+ this.db.run(
+ "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ?",
+ row.started_at,
+ itemId,
+ userId,
+ );
+ this.markAccepted(row.id, 'bucket_list', { bucketItemId: itemId });
+ });
+
+ return {
+ suggestion: this.getOne(userId, row.id)!,
+ createdPlaceId: null,
+ createdJournalEntryId: null,
+ bucketListItemId: itemId,
+ };
+ }
+
+ /**
+ * The instance's answer to "may this person do this on this trip".
+ *
+ * The role comes out of the users table because the only thing every caller
+ * has in common is a user id: the REST controller holds the full user, the MCP
+ * tool holds `ctx.userId`, and a permission that depended on which door
+ * somebody came through would not be a permission.
+ */
+ private requirePermission(action: 'place_edit' | 'day_edit', tripOwnerId: number, userId: number): void {
+ const actor = this.db.get<{ role: string }>('SELECT role FROM users WHERE id = ?', userId);
+ const allowed = this.permissions.checkPermission(
+ action,
+ actor?.role ?? 'user',
+ tripOwnerId,
+ userId,
+ tripOwnerId !== userId,
+ );
+ if (!allowed) throw new AcceptError('forbidden', 'No permission', 403);
+ }
+
+ /**
+ * Record what the acceptance produced, and freeze the hash it was accepted at.
+ *
+ * `accepted_hash` is what makes "the source changed since you accepted this"
+ * answerable at all: without it there is nothing to compare the current hash
+ * against, and the flag would either never fire or fire forever.
+ */
+ private markAccepted(
+ id: number,
+ target: 'place' | 'journal' | 'bucket_list',
+ ids: { placeId?: number; journalEntryId?: number; bucketItemId?: number },
+ ): void {
+ this.db.run(
+ `UPDATE dawarich_visit_suggestions
+ SET state = 'accepted', target = ?, accepted_place_id = ?,
+ accepted_journal_entry_id = ?, accepted_bucket_list_item_id = ?,
+ accepted_hash = source_hash
+ WHERE id = ?`,
+ target,
+ ids.placeId ?? null,
+ ids.journalEntryId ?? null,
+ ids.bucketItemId ?? null,
+ id,
+ );
+ }
+
+ // ── Bucket-list scan ───────────────────────────────────────────────────────
+
+ /**
+ * Ask Dawarich, wish by wish, whether the user ever actually got there.
+ *
+ * This is the thorough half of the bucket-list match. The sync already links
+ * a wish to a stay when Dawarich's own detector happened to produce one
+ * nearby; this asks the recordings directly, which finds the places the
+ * detector never called a visit — the viewpoint you stopped at for half an
+ * hour on the way somewhere else.
+ *
+ * One upstream request per wish, so it is capped and explicitly triggered
+ * rather than run on a timer. The cap is reported, never silent: a user with
+ * eighty wishes must not be told their other thirty had no match.
+ */
+ async scanBucketList(userId: number): Promise {
+ const creds = this.dawarich.getCredentials(userId);
+ if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
+
+ const items = this.db.all(
+ `SELECT id, name, lat, lng, visited_at
+ FROM bucket_list WHERE user_id = ?
+ ORDER BY (visited_at IS NOT NULL), created_at DESC, id DESC`,
+ userId,
+ );
+ const withCoords = items.filter((item) => item.lat !== null && item.lng !== null);
+ const skipped = items.length - withCoords.length;
+ const batch = withCoords.slice(0, DAWARICH_BUCKET_SCAN_LIMIT);
+
+ const matches: DawarichBucketMatch[] = [];
+ for (const item of batch) {
+ matches.push({
+ itemId: item.id,
+ name: item.name,
+ match: await this.bestStayNear(creds, item.lat!, item.lng!),
+ alreadyVisited: item.visited_at !== null,
+ });
+ }
+
+ return {
+ matches,
+ skippedWithoutCoordinates: skipped,
+ truncated: withCoords.length > batch.length,
+ fetchedAt: new Date().toISOString(),
+ };
+ }
+
+ /**
+ * The longest recorded stay near a coordinate that clears both thresholds.
+ *
+ * Longest rather than nearest: of two stays at the same landmark, the one
+ * that lasted two hours is the visit and the one that lasted four minutes is
+ * the bus stopping outside.
+ */
+ private async bestStayNear(
+ creds: DawarichCreds,
+ lat: number,
+ lng: number,
+ ): Promise {
+ let stays;
+ try {
+ stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20);
+ } catch {
+ // One wish that could not be checked is not a failed scan; it reports as
+ // "no match found" and the user can run it again.
+ return null;
+ }
+
+ let best: DawarichBucketMatch['match'] = null;
+ for (const stay of stays) {
+ const details = stay.visit_details ?? null;
+ const minutes =
+ toNumber(details?.duration_minutes) ??
+ (details?.start_time && details?.end_time ? minutesBetween(details.start_time, details.end_time) : 0);
+ if (minutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) continue;
+
+ const distance = toNumber(stay.distance_meters) ?? DAWARICH_BUCKET_MATCH_RADIUS_M;
+ if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue;
+
+ // A stay with no readable timestamp at either end cannot be dated, and a
+ // wish ticked off on 1970-01-01 is worse than one that is not offered.
+ const at = details?.start_time ?? isoFromUnix(toNumber(stay.timestamp));
+ if (!at) continue;
+ if (!best || minutes > best.minutes) {
+ best = {
+ at,
+ minutes,
+ distanceMeters: Math.round(distance),
+ points: toNumber(stay.points_count) ?? 0,
+ };
+ }
+ }
+ return best;
+ }
+
+ /** Tick off wishes the user confirmed after reading a scan. */
+ confirmBucketVisits(userId: number, itemIds: number[], visitedAt?: string): number {
+ const now = new Date().toISOString();
+ let updated = 0;
+ this.db.transaction(() => {
+ for (const itemId of itemIds) {
+ // The date is the one the wish was actually reached on. A wish somebody
+ // got to in 2023 ticked off with today's date is a wrong entry in a list
+ // people keep for years, so "now" is only the answer when nothing else
+ // knows better: the caller's own value first, then the stay this wish
+ // was matched to, then the clock.
+ const stamp = visitedAt ?? this.matchedStayStart(userId, itemId) ?? now;
+ const result = this.db.run(
+ "UPDATE bucket_list SET visited_at = ?, visited_source = 'dawarich' WHERE id = ? AND user_id = ? AND visited_at IS NULL",
+ stamp,
+ itemId,
+ userId,
+ );
+ updated += result.changes;
+ }
+ });
+ return updated;
+ }
+
+ /** When the stay that claimed this wish began, if one did. */
+ private matchedStayStart(userId: number, itemId: number): string | null {
+ const row = this.db.get<{ started_at: string }>(
+ `SELECT started_at FROM dawarich_visit_suggestions
+ WHERE user_id = ? AND matched_bucket_list_item_id = ?
+ ORDER BY started_at DESC LIMIT 1`,
+ userId,
+ itemId,
+ );
+ return row?.started_at ?? null;
+ }
+
+ /** Undo a tick. Clears the source with it, so the entry reads as untouched again. */
+ clearBucketVisit(userId: number, itemId: number): boolean {
+ const result = this.db.run(
+ 'UPDATE bucket_list SET visited_at = NULL, visited_source = NULL WHERE id = ? AND user_id = ?',
+ itemId,
+ userId,
+ );
+ return result.changes > 0;
+ }
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+
+ /**
+ * Countries and cities the recordings put the user in, offered to the Atlas.
+ *
+ * Offered, never applied. The Atlas is a thing people curate — it has
+ * tombstones precisely so a country somebody removed stays removed — and a
+ * background job that ticked countries off would fight that. `alreadyVisited`
+ * marks the ones TREK already counts, so the list reads as "these are new"
+ * rather than as a wall of things the user has known for years.
+ *
+ * Dawarich names countries rather than coding them, so the name is mapped
+ * through the Atlas's own table. Anything that does not resolve is listed by
+ * name instead of being dropped: a country TREK cannot code is still a
+ * country the user went to, and silently losing it would be the worse bug.
+ */
+ async atlasSuggestions(userId: number, from: Date, to: Date): Promise {
+ const creds = this.dawarich.getCredentials(userId);
+ if (!creds) throw new AcceptError('not_connected', 'Dawarich is not connected', 400);
+
+ const countries = await this.client.listVisitedCities(creds, from, to);
+ const visited = new Set(
+ this.db
+ .all<{ country_code: string }>('SELECT country_code FROM visited_countries WHERE user_id = ?', userId)
+ .map((r) => r.country_code.toUpperCase()),
+ );
+
+ const resolved: DawarichAtlasCountry[] = [];
+ const unresolved: string[] = [];
+
+ for (const entry of countries) {
+ const name = (entry?.country ?? '').trim();
+ if (!name) continue;
+ const code = NAME_TO_CODE[name.toLowerCase()];
+ if (!code) {
+ unresolved.push(name);
+ continue;
+ }
+ resolved.push({
+ countryCode: code,
+ sourceName: name,
+ cities: (entry.cities ?? []).map((city) => ({
+ name: city?.city ?? '',
+ minutes: toNumber(city?.stayed_for) ?? 0,
+ lastSeenAt: isoFromUnix(toNumber(city?.timestamp)),
+ })).filter((city) => city.name !== ''),
+ alreadyVisited: visited.has(code),
+ });
+ }
+
+ return { countries: resolved, unresolved, fetchedAt: new Date().toISOString() };
+ }
+
+ /**
+ * Confirm a batch of countries into the Atlas.
+ *
+ * Goes through `AtlasService.markCountry`, which is what lifts the tombstone
+ * a previous removal left behind and keeps the two write paths from drifting.
+ * `INSERT OR IGNORE` underneath means a country already marked by hand keeps
+ * its own provenance — confirming it again does not relabel it as imported.
+ */
+ acceptAtlasCountries(userId: number, codes: string[]): number {
+ let marked = 0;
+ this.db.transaction(() => {
+ for (const raw of codes) {
+ const code = raw.trim().toUpperCase();
+ if (!/^[A-Z]{2}$/.test(code)) continue;
+ // Counted only when it was actually added: "3 countries added" must not
+ // include ones that were already on the map.
+ if (this.atlas.markCountry(userId, code, 'dawarich')) marked++;
+ }
+ });
+ return marked;
+ }
+}
+
+/** A refusal with a code the controller and the MCP tool each render their own way. */
+export class AcceptError extends Error {
+ readonly code: string;
+ readonly status: number;
+ constructor(code: string, message: string, status: number) {
+ super(message);
+ this.name = 'AcceptError';
+ this.code = code;
+ this.status = status;
+ }
+}
+
+/** `HH:MM` out of an ISO timestamp, in the offset the timestamp carries. */
+function timeOf(iso: string): string | null {
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(iso) ? iso.slice(11, 16) : null;
+}
+
+function toWire(row: SuggestionJoinRow): DawarichSuggestion {
+ return {
+ id: row.id,
+ sourceVisitId: row.source_visit_id,
+ tripId: row.trip_id,
+ tripTitle: row.trip_title ?? null,
+ name: row.name,
+ lat: row.lat,
+ lng: row.lng,
+ startedAt: row.started_at,
+ endedAt: row.ended_at,
+ durationMinutes: row.duration_minutes,
+ localDate: row.local_date,
+ sourceStatus: row.source_status === 'confirmed' ? 'confirmed' : 'suggested',
+ confidence: row.confidence,
+ confidenceBand: row.confidence_band,
+ state: row.state === 'accepted' || row.state === 'dismissed' ? row.state : 'new',
+ target:
+ row.target === 'place' || row.target === 'journal' || row.target === 'bucket_list' ? row.target : null,
+ acceptedPlaceId: row.accepted_place_id,
+ acceptedJournalEntryId: row.accepted_journal_entry_id,
+ acceptedBucketListItemId: row.accepted_bucket_list_item_id,
+ // Only meaningful once something was accepted: before that there is no
+ // "what you agreed to" to have diverged from.
+ sourceChanged: row.accepted_hash !== null && row.accepted_hash !== row.source_hash,
+ sourceMissing: row.source_missing_at !== null,
+ matchedBucketListItemId: row.matched_bucket_list_item_id,
+ matchedBucketListName: row.matched_bucket_name ?? null,
+ countryCode: row.country_code,
+ firstSeenAt: row.first_seen_at,
+ lastSeenAt: row.last_seen_at,
+ };
+}
+
+/** A Unix timestamp as ISO-8601, or null when there was none to read. */
+function isoFromUnix(seconds: number | null | undefined): string | null {
+ if (seconds === null || seconds === undefined || !Number.isFinite(seconds)) return null;
+ return new Date(seconds * 1000).toISOString();
+}
+
+interface SuggestionRow {
+ id: number;
+ user_id: number;
+ source_visit_id: string;
+ trip_id: number | null;
+ name: string;
+ lat: number | null;
+ lng: number | null;
+ started_at: string;
+ ended_at: string;
+ duration_minutes: number;
+ local_date: string;
+ source_status: string;
+ confidence: number | null;
+ confidence_band: string | null;
+ country_code: string | null;
+ state: string;
+ target: string | null;
+ accepted_place_id: number | null;
+ accepted_journal_entry_id: number | null;
+ accepted_bucket_list_item_id: number | null;
+ matched_bucket_list_item_id: number | null;
+ source_hash: string;
+ accepted_hash: string | null;
+ source_missing_at: string | null;
+ first_seen_at: string;
+ last_seen_at: string;
+}
+
+interface SuggestionJoinRow extends SuggestionRow {
+ trip_title: string | null;
+ matched_bucket_name: string | null;
+}
+
+interface BucketRow {
+ id: number;
+ name: string;
+ lat: number | null;
+ lng: number | null;
+ visited_at: string | null;
+}
diff --git a/server/src/nest/integrations/dawarich-sync.job.ts b/server/src/nest/integrations/dawarich-sync.job.ts
new file mode 100644
index 000000000..779ff7657
--- /dev/null
+++ b/server/src/nest/integrations/dawarich-sync.job.ts
@@ -0,0 +1,47 @@
+import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
+import { logInfo, logError } from '../audit/audit-log.logger';
+import { DatabaseService } from '../database/database.service';
+import { CronRegistrarService } from '../scheduling/cron-registrar.service';
+import { DawarichSyncService } from './dawarich-sync.service';
+
+/**
+ * Polls every connected Dawarich instance for new visits (#2279).
+ *
+ * Slower than the AirTrail job by an order of magnitude, and on purpose: a
+ * flight lands once, while a visit detector produces stays continuously, and
+ * nobody needs a stay from four minutes ago on their planning screen. Fifteen
+ * minutes keeps a whole afternoon's suggestions arriving in time to be useful
+ * without turning a self-hosted instance into a busy server.
+ *
+ * The addon gate is re-read per tick inside the service, so switching the addon
+ * off takes effect without a restart; the interval is read once at bootstrap,
+ * matching how AirTrail behaves.
+ */
+@Injectable()
+export class DawarichSyncJob implements OnApplicationBootstrap {
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly sync: DawarichSyncService,
+ private readonly registrar: CronRegistrarService,
+ ) {}
+
+ onApplicationBootstrap(): void {
+ if (!this.registrar.isEnabled()) return;
+ const value = this.db.get<{ value: string }>(
+ 'SELECT value FROM app_settings WHERE key = ?',
+ 'dawarich_poll_interval_minutes',
+ )?.value;
+ const raw = Number.parseInt(value || '15', 10);
+ const minutes = Number.isFinite(raw) && raw >= 5 && raw <= 59 ? raw : 15;
+ logInfo(`Dawarich sync: scheduled every ${minutes}m`);
+ this.registrar.register('dawarich-sync', `*/${minutes} * * * *`, () => this.tick());
+ }
+
+ async tick(): Promise {
+ try {
+ await this.sync.runSync();
+ } catch (err: unknown) {
+ logError(`Dawarich sync tick failed: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+}
diff --git a/server/src/nest/integrations/dawarich-sync.service.ts b/server/src/nest/integrations/dawarich-sync.service.ts
new file mode 100644
index 000000000..08d8f5dce
--- /dev/null
+++ b/server/src/nest/integrations/dawarich-sync.service.ts
@@ -0,0 +1,525 @@
+import { Injectable } from '@nestjs/common';
+import {
+ DAWARICH_SYNC_LOOKAHEAD_DAYS,
+ DAWARICH_SYNC_LOOKBACK_DAYS,
+ DAWARICH_BUCKET_MATCH_RADIUS_M,
+ DAWARICH_BUCKET_MATCH_MIN_MINUTES,
+ type DawarichSyncState,
+} from '@trek/shared';
+import { ADDON_IDS } from '../../addons';
+import { DatabaseService } from '../database/database.service';
+import { AddonsService } from '../addons/addons.service';
+import { logError, logInfo } from '../audit/audit-log.logger';
+import { getCountryFromCoords } from '../atlas/atlas-geo';
+import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
+import { DawarichService } from './dawarich.service';
+import { distanceMeters, localDateOf, normalizeVisit, syncWindow, visitHash } from './dawarich.helpers';
+
+/** What one user's sync produced — plus whether it ran at all. */
+export interface DawarichSyncOutcome {
+ state: DawarichSyncState;
+ created: number;
+ updated: number;
+ missing: number;
+ /** True when a sync for this user was already in flight, so nothing was asked. */
+ alreadyRunning?: boolean;
+}
+
+/**
+ * Pulls Dawarich visits into TREK as reviewable suggestions.
+ *
+ * Three rules run through everything here, and they are the whole reason this
+ * is a sync rather than an import:
+ *
+ * 1. **A sync never changes shared trip content.** It writes rows into
+ * `dawarich_visit_suggestions`, which belong to the person whose credentials
+ * fetched them. A place or a journal entry appears only when that person
+ * accepts one, in `DawarichSuggestionsService`.
+ * 2. **Repeating a sync is free.** Identity is (user, Dawarich visit id) with a
+ * UNIQUE behind it, so the tenth run over the same window produces the same
+ * rows as the first.
+ * 3. **The source is allowed to change its mind; TREK is not allowed to act on
+ * that alone.** A visit whose content hash moved is flagged `source_changed`
+ * and one that vanished from a full re-read is flagged `source_missing` —
+ * both visible, neither applied. A suggestion the user already accepted and
+ * then edited is their text now; rewriting or deleting it because a detector
+ * ran again would be the integration overwriting someone's journal.
+ */
+@Injectable()
+export class DawarichSyncService {
+ /**
+ * Module-level rather than per-call: a cron tick that overlaps the previous
+ * one would ask the same instance for the same window twice and race on the
+ * same rows.
+ */
+ private running = false;
+
+ /**
+ * Who is being synced right now, cron or button.
+ *
+ * The module-level flag only guards the cron against itself. "Check now" calls
+ * `syncUser` directly, so without this a held-down button — or a button pressed
+ * while the cron is working — would ask the same instance for the same windows
+ * several times over, each walking up to 20 pages, and let two passes race on
+ * the same rows. A second request is answered with the truth: one is running.
+ */
+ private readonly inFlight = new Set();
+
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly addons: AddonsService,
+ private readonly client: DawarichClient,
+ private readonly dawarich: DawarichService,
+ ) {}
+
+ /** The addon gate, evaluated per tick so an admin toggle lands without a restart. */
+ syncGloballyEnabled(): boolean {
+ return this.addons.isAddonEnabled(ADDON_IDS.DAWARICH);
+ }
+
+ /** Every connected user, one after another. The cron's entry point. */
+ async runSync(): Promise {
+ if (!this.syncGloballyEnabled()) return;
+ if (this.running) return;
+ this.running = true;
+ try {
+ const userIds = this.dawarich.listSyncableUserIds();
+ for (const userId of userIds) {
+ try {
+ await this.syncUser(userId);
+ } catch (err) {
+ // One unreachable instance must not stop the others.
+ logError(`Dawarich sync failed for user ${userId}: ${err instanceof Error ? err.message : err}`);
+ }
+ }
+ } finally {
+ this.running = false;
+ }
+ }
+
+ /**
+ * One user's trips.
+ *
+ * The result is deliberately three-valued. `partial` is the interesting one:
+ * some windows came back and some did not, which is what a flaky instance or
+ * a very long trip actually looks like, and calling that either "ok" or
+ * "failed" would mislead the person reading the connection card.
+ */
+ async syncUser(userId: number): Promise {
+ if (this.inFlight.has(userId)) {
+ // Not a failure and not a fresh result: the run that is already going will
+ // record its own. Reporting the state the connection currently holds keeps
+ // the card honest, and `alreadyRunning` lets the caller say so.
+ const current = this.db.get<{ last_sync_state: string }>(
+ 'SELECT last_sync_state FROM dawarich_connections WHERE user_id = ?',
+ userId,
+ );
+ return {
+ state: (current?.last_sync_state as DawarichSyncState) ?? 'never',
+ created: 0,
+ updated: 0,
+ missing: 0,
+ alreadyRunning: true,
+ };
+ }
+ this.inFlight.add(userId);
+ try {
+ return await this.syncUserOnce(userId);
+ } finally {
+ this.inFlight.delete(userId);
+ }
+ }
+
+ /** The body of a sync, with the guard above already held. */
+ private async syncUserOnce(userId: number): Promise {
+ if (!this.syncGloballyEnabled()) {
+ this.dawarich.recordSyncResult(userId, 'failed', 'addon_disabled');
+ return { state: 'failed', created: 0, updated: 0, missing: 0 };
+ }
+
+ const creds = this.dawarich.getCredentials(userId);
+ if (!creds) {
+ this.dawarich.recordSyncResult(userId, 'failed', 'not_connected');
+ return { state: 'failed', created: 0, updated: 0, missing: 0 };
+ }
+
+ const trips = this.listTripsToSync(userId);
+ if (trips.length === 0) {
+ // Nothing to ask about is a successful sync, not a failure — otherwise a
+ // user with no dated trips sees a permanent red badge for doing nothing wrong.
+ this.dawarich.recordSyncResult(userId, 'ok', null);
+ return { state: 'ok', created: 0, updated: 0, missing: 0 };
+ }
+
+ let created = 0;
+ let updated = 0;
+ let missing = 0;
+ let failures = 0;
+ let lastError: string | null = null;
+ const now = new Date();
+
+ for (const trip of trips) {
+ const window = syncWindow(
+ trip.start_date,
+ trip.end_date,
+ now,
+ DAWARICH_SYNC_LOOKBACK_DAYS,
+ DAWARICH_SYNC_LOOKAHEAD_DAYS,
+ );
+ if (!window) continue;
+
+ try {
+ const result = await this.syncTripWindow(userId, trip.id, creds, window.from, window.to);
+ created += result.created;
+ updated += result.updated;
+ missing += result.missing;
+ } catch (err) {
+ failures++;
+ lastError = err instanceof DawarichError ? err.code : 'unreachable';
+ }
+ }
+
+ const state: DawarichSyncState =
+ failures === 0 ? 'ok' : failures === trips.length ? 'failed' : 'partial';
+ this.dawarich.recordSyncResult(userId, state, state === 'ok' ? null : lastError);
+
+ // The probe is cheap next to the windows just fetched, and re-running it is
+ // how a Dawarich upgrade that finally ships `updated_at` starts being used
+ // without anyone reconnecting.
+ if (state !== 'failed') {
+ try {
+ this.dawarich.storeCapabilities(userId, await this.dawarich.probeCapabilities(creds));
+ } catch {
+ // Capabilities are an optimisation; a failed probe is not a failed sync.
+ }
+ }
+
+ if (created || updated || missing) {
+ logInfo(`Dawarich sync for user ${userId}: ${created} new, ${updated} changed, ${missing} gone`);
+ }
+ return { state, created, updated, missing };
+ }
+
+ /**
+ * Trips worth asking about: the ones the user owns or is on, not archived,
+ * with a start date, and either still running or recent enough that a
+ * recording could still arrive.
+ *
+ * A trip five years past is not re-polled every five minutes. The cut-off is
+ * generous rather than tight, because someone who connects Dawarich for the
+ * first time wants their last holiday filled in, not just today's.
+ */
+ private listTripsToSync(userId: number): TripRow[] {
+ return this.db.all(
+ `SELECT DISTINCT t.id, t.start_date, t.end_date
+ FROM trips t
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
+ WHERE (t.user_id = ? OR m.user_id IS NOT NULL)
+ AND COALESCE(t.is_archived, 0) = 0
+ AND t.start_date IS NOT NULL
+ AND (t.end_date IS NULL OR t.end_date >= date('now', '-400 days'))
+ AND t.start_date <= date('now', '+1 day')
+ ORDER BY t.start_date DESC`,
+ userId,
+ userId,
+ );
+ }
+
+ /**
+ * One trip's window: fetch, reconcile, flag what disappeared.
+ *
+ * The reconciliation is a full re-read of the window rather than a delta,
+ * and that is not laziness. Dawarich sends no `updated_at` to build a delta
+ * from, and deleting a visit removes it from the list rather than tombstoning
+ * it — so a changed-since filter, even if one existed, could never see a
+ * deletion. Comparing the whole window against what TREK already holds is the
+ * only thing that can.
+ */
+ async syncTripWindow(
+ userId: number,
+ tripId: number,
+ creds: DawarichCreds,
+ from: Date,
+ to: Date,
+ ): Promise<{ created: number; updated: number; missing: number }> {
+ const { visits } = await this.client.listVisits(creds, from, to);
+
+ const seenIds = new Set();
+ let created = 0;
+ let updated = 0;
+
+ for (const raw of visits) {
+ const visit = normalizeVisit(raw);
+ if (!visit) continue;
+ seenIds.add(visit.sourceVisitId);
+
+ const hash = visitHash(raw);
+ const existing = this.db.get(
+ 'SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? AND source_visit_id = ?',
+ userId,
+ visit.sourceVisitId,
+ );
+
+ // Dawarich has no country code on a visit's place, so it is resolved here
+ // from the coordinates against the same borders the Atlas draws. A code
+ // the source does start sending one day wins, because it is the source's
+ // own answer.
+ const countryCode =
+ visit.countryCodeFromSource ??
+ (visit.lat !== null && visit.lng !== null ? getCountryFromCoords(visit.lat, visit.lng) : null);
+
+ if (!existing) {
+ this.db.run(
+ `INSERT INTO dawarich_visit_suggestions
+ (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at,
+ duration_minutes, local_date, source_status, confidence, confidence_band,
+ country_code, state, source_hash, first_seen_at, last_seen_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'new', ?, ?, ?)`,
+ userId,
+ visit.sourceVisitId,
+ tripId,
+ visit.name,
+ visit.lat,
+ visit.lng,
+ visit.startedAt,
+ visit.endedAt,
+ visit.durationMinutes,
+ visit.localDate,
+ visit.status,
+ visit.confidence,
+ visit.confidenceBand,
+ countryCode,
+ hash,
+ new Date().toISOString(),
+ new Date().toISOString(),
+ );
+ created++;
+ this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
+ continue;
+ }
+
+ const changed = existing.source_hash !== hash;
+
+ if (existing.state === 'new') {
+ // Nobody has acted on it yet, so the newest version of the source is
+ // simply the better suggestion. Overwriting here loses nothing.
+ this.db.run(
+ `UPDATE dawarich_visit_suggestions
+ SET name = ?, lat = ?, lng = ?, started_at = ?, ended_at = ?, duration_minutes = ?,
+ local_date = ?, source_status = ?, confidence = ?, confidence_band = ?,
+ country_code = ?, source_hash = ?, source_missing_at = NULL,
+ trip_id = COALESCE(trip_id, ?), last_seen_at = ?
+ WHERE id = ?`,
+ visit.name,
+ visit.lat,
+ visit.lng,
+ visit.startedAt,
+ visit.endedAt,
+ visit.durationMinutes,
+ visit.localDate,
+ visit.status,
+ visit.confidence,
+ visit.confidenceBand,
+ countryCode,
+ hash,
+ tripId,
+ new Date().toISOString(),
+ existing.id,
+ );
+ if (changed) updated++;
+ this.matchBucketList(userId, visit.sourceVisitId, visit.lat, visit.lng, visit.durationMinutes);
+ continue;
+ }
+
+ // Accepted or dismissed: the hash is refreshed so the flag is raised once
+ // rather than on every tick, but nothing the user can see is rewritten.
+ // `accepted_hash` is what they said yes to; the difference between that
+ // and the current hash is what the UI reports.
+ this.db.run(
+ `UPDATE dawarich_visit_suggestions
+ SET source_hash = ?, source_missing_at = NULL, last_seen_at = ?
+ WHERE id = ?`,
+ hash,
+ new Date().toISOString(),
+ existing.id,
+ );
+ if (changed) updated++;
+ }
+
+ const missing = this.flagMissing(userId, tripId, from, to, seenIds);
+ return { created, updated, missing };
+ }
+
+ /**
+ * Mark everything TREK holds for this window that the source no longer lists.
+ *
+ * Untouched suggestions are removed outright — nobody has seen them, and a
+ * list of stays that no longer exist is worse than an empty list. Anything the
+ * user acted on keeps its row and gets a timestamp instead, so the panel can
+ * say "this is gone in Dawarich" next to an entry they wrote. Deleting that
+ * would delete their work.
+ */
+ private flagMissing(
+ userId: number,
+ tripId: number,
+ from: Date,
+ to: Date,
+ seenIds: Set,
+ ): number {
+ // Bounded on `local_date`, not on `started_at`. Dawarich renders a visit's
+ // start as local wall-clock plus an offset (`2026-09-01T14:23:00+02:00`)
+ // while the window is UTC (`…Z`), and comparing those two shapes as strings
+ // is only accurate to the date — on a statement that DELETES rows, "only
+ // accurate to the date" is not good enough. `local_date` is a plain
+ // YYYY-MM-DD on both sides of the comparison, and a day of slack at each
+ // end is deliberate: a row just outside the window is left alone rather
+ // than deleted for not having been seen.
+ const candidates = this.db.all<{ id: number; source_visit_id: string; state: string }>(
+ `SELECT id, source_visit_id, state
+ FROM dawarich_visit_suggestions
+ WHERE user_id = ? AND trip_id = ?
+ AND local_date >= ? AND local_date <= ?`,
+ userId,
+ tripId,
+ localDateOf(from.toISOString()),
+ localDateOf(to.toISOString()),
+ );
+
+ const gone = candidates.filter((row) => !seenIds.has(row.source_visit_id));
+ if (gone.length === 0) return 0;
+
+ const stamp = new Date().toISOString();
+ this.db.transaction(() => {
+ for (const row of gone) {
+ if (row.state === 'new') {
+ this.db.run('DELETE FROM dawarich_visit_suggestions WHERE id = ?', row.id);
+ } else {
+ this.db.run(
+ 'UPDATE dawarich_visit_suggestions SET source_missing_at = COALESCE(source_missing_at, ?) WHERE id = ?',
+ stamp,
+ row.id,
+ );
+ }
+ }
+ });
+ return gone.length;
+ }
+
+ /**
+ * Link a stay to a bucket-list wish it sits on top of.
+ *
+ * Both tests have to pass: close enough, and long enough. Proximity alone is
+ * what makes a detector untrustworthy — driving past a cathedral puts you
+ * within 250 m of it — and the dwell time is what separates being somewhere
+ * from going past it. The link is only a hint; ticking the wish off still
+ * needs the user.
+ */
+ private matchBucketList(
+ userId: number,
+ sourceVisitId: string,
+ lat: number | null,
+ lng: number | null,
+ durationMinutes: number,
+ ): void {
+ if (lat === null || lng === null) return;
+ if (durationMinutes < DAWARICH_BUCKET_MATCH_MIN_MINUTES) return;
+
+ // A rough box first so SQLite does not measure every wish in the world. One
+ // degree of latitude is ~111 km, and longitude shrinks with latitude, so the
+ // box is deliberately generous and the real test is the distance below.
+ const degrees = (DAWARICH_BUCKET_MATCH_RADIUS_M / 111_000) * 2 + 0.01;
+ const nearby = this.db.all<{ id: number; lat: number; lng: number }>(
+ `SELECT id, lat, lng FROM bucket_list
+ WHERE user_id = ? AND lat IS NOT NULL AND lng IS NOT NULL
+ AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?`,
+ userId,
+ lat - degrees,
+ lat + degrees,
+ lng - degrees,
+ lng + degrees,
+ );
+
+ let best: { id: number; lat: number; lng: number; distance: number } | null = null;
+ for (const item of nearby) {
+ const distance = distanceMeters(lat, lng, item.lat, item.lng);
+ if (distance > DAWARICH_BUCKET_MATCH_RADIUS_M) continue;
+ if (!best || distance < best.distance) best = { id: item.id, lat: item.lat, lng: item.lng, distance };
+ }
+ if (!best) return;
+
+ this.claimWish(userId, sourceVisitId, best, lat, lng, durationMinutes);
+ }
+
+ /**
+ * Give a wish to the one stay that best answers it.
+ *
+ * 250 m is a city block, and a block in a city centre holds a dozen stays: a
+ * coffee across the square from the museum satisfies the radius exactly as
+ * the museum does. Attaching the wish to each of them turns one achievement
+ * into five claims and invites ticking it off from the wrong one, so the
+ * closest stay keeps it — and the longest wins a tie, because standing
+ * somewhere for two hours is a better answer to "were you there" than passing
+ * within the same few metres.
+ *
+ * Every previous claim on the same wish is cleared, so re-running a sync after
+ * the recordings change cannot leave two stays holding it.
+ */
+ private claimWish(
+ userId: number,
+ sourceVisitId: string,
+ wish: { id: number; lat: number; lng: number; distance: number },
+ lat: number,
+ lng: number,
+ durationMinutes: number,
+ ): void {
+ const holders = this.db.all<{
+ id: number;
+ source_visit_id: string;
+ lat: number | null;
+ lng: number | null;
+ duration_minutes: number;
+ }>(
+ `SELECT id, source_visit_id, lat, lng, duration_minutes
+ FROM dawarich_visit_suggestions
+ WHERE user_id = ? AND matched_bucket_list_item_id = ? AND source_visit_id <> ?`,
+ userId,
+ wish.id,
+ sourceVisitId,
+ );
+
+ for (const holder of holders) {
+ if (holder.lat === null || holder.lng === null) continue;
+ const distance = distanceMeters(holder.lat, holder.lng, wish.lat, wish.lng);
+ const holderWins =
+ distance < wish.distance ||
+ (distance === wish.distance && holder.duration_minutes > durationMinutes);
+ if (holderWins) return;
+ }
+
+ this.db.transaction(() => {
+ this.db.run(
+ 'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = NULL WHERE user_id = ? AND matched_bucket_list_item_id = ?',
+ userId,
+ wish.id,
+ );
+ this.db.run(
+ 'UPDATE dawarich_visit_suggestions SET matched_bucket_list_item_id = ? WHERE user_id = ? AND source_visit_id = ?',
+ wish.id,
+ userId,
+ sourceVisitId,
+ );
+ });
+ }
+}
+
+interface TripRow {
+ id: number;
+ start_date: string | null;
+ end_date: string | null;
+}
+
+interface SuggestionRow {
+ id: number;
+ state: string;
+ source_hash: string;
+}
diff --git a/server/src/nest/integrations/dawarich-tracks.service.ts b/server/src/nest/integrations/dawarich-tracks.service.ts
new file mode 100644
index 000000000..386ee0f63
--- /dev/null
+++ b/server/src/nest/integrations/dawarich-tracks.service.ts
@@ -0,0 +1,194 @@
+import { Injectable } from '@nestjs/common';
+import { DAWARICH_TRACK_POINTS_PER_DAY, type DawarichTrack } from '@trek/shared';
+import { DatabaseService } from '../database/database.service';
+import { DawarichClient, type DawarichCreds } from './dawarich.client';
+import { DawarichService } from './dawarich.service';
+import {
+ bucketPointsByDay,
+ bucketTracksByDay,
+ countPoints,
+ offsetMinutesOf,
+} from './dawarich.helpers';
+
+/**
+ * The recorded route for a window, fetched on demand and never stored.
+ *
+ * That is the point of the whole feature and the one rule it must not break:
+ * Dawarich owns the location archive. TREK draws the part of it somebody is
+ * looking at right now and forgets it again. There is no table here, no
+ * `route_geometry` written to a place, no background import.
+ *
+ * What there is instead is a short in-memory cache, because "update the line
+ * while the map is open" and "ask an instance for a month of GPS every few
+ * seconds" are the same request otherwise. It lives for a minute, it is keyed
+ * per user and window, and losing it on restart costs one extra fetch.
+ */
+@Injectable()
+export class DawarichTracksService {
+ private readonly cache = new Map();
+
+ /**
+ * Long enough that panning and toggling the layer do not re-fetch, short
+ * enough that a live recording visibly catches up.
+ */
+ private static readonly TTL_MS = 60_000;
+
+ /**
+ * A ceiling, not a sizing estimate: the cache holds whole days of geometry
+ * and a user flipping through a year of trips would otherwise keep every one
+ * of them alive. Oldest entry out first.
+ */
+ private static readonly MAX_ENTRIES = 64;
+
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly dawarich: DawarichService,
+ private readonly client: DawarichClient,
+ ) {}
+
+ /**
+ * The recorded route for a trip, grouped by local day.
+ *
+ * `from`/`to` narrow it further so the planner can ask for one day without
+ * pulling the whole trip; without them the trip's own dates are the window.
+ * Returns null when the caller cannot read the trip — the controller turns
+ * that into the same 404 a missing trip gets.
+ */
+ async forTrip(
+ userId: number,
+ tripId: number,
+ from?: string,
+ to?: string,
+ offsetMinutes = 0,
+ ): Promise {
+ if (!this.db.canAccessTrip(tripId, userId)) return null;
+
+ const trip = this.db.get<{ start_date: string | null; end_date: string | null }>(
+ 'SELECT start_date, end_date FROM trips WHERE id = ?',
+ tripId,
+ );
+ if (!trip) return null;
+
+ const start = from ?? trip.start_date;
+ const end = to ?? trip.end_date ?? trip.start_date;
+ if (!start || !end) {
+ // A trip without dates has no window to ask about. An empty result says
+ // so honestly; an error would suggest the connection is broken.
+ return emptyTrack();
+ }
+
+ return this.forWindow(userId, `${start}T00:00:00Z`, `${end}T23:59:59Z`, offsetMinutes);
+ }
+
+ /**
+ * The recorded route for an explicit instant range.
+ *
+ * Used by the journal, which has dated entries rather than a trip window.
+ */
+ async forWindow(
+ userId: number,
+ fromIso: string,
+ toIso: string,
+ offsetMinutes = offsetMinutesOf(fromIso),
+ ): Promise {
+ const creds = this.dawarich.getCredentials(userId);
+ if (!creds) return emptyTrack();
+
+ const from = new Date(fromIso);
+ const to = new Date(toIso);
+ if (!Number.isFinite(from.getTime()) || !Number.isFinite(to.getTime()) || to <= from) {
+ return emptyTrack();
+ }
+
+ // The offset is part of the key: the same window grouped by two different
+ // days is two different answers, and one traveller can ask for both across
+ // a timezone change.
+ const key = `${userId}:${from.toISOString()}:${to.toISOString()}:${offsetMinutes}`;
+ const hit = this.cache.get(key);
+ if (hit && Date.now() - hit.at < DawarichTracksService.TTL_MS) return hit.value;
+
+ const value = await this.fetch(creds, userId, from, to, offsetMinutes);
+ this.remember(key, value);
+ return value;
+ }
+
+ /**
+ * Tracks first, points as the fallback.
+ *
+ * `/tracks` returns lines Dawarich has already segmented and labelled with a
+ * travel mode, which is both a smaller payload and a better line than raw
+ * points. But tracks are produced by a background job, so a fresh import —
+ * or an instance old enough not to have the endpoint — has points long before
+ * it has tracks. Falling back keeps the overlay working on both, and the
+ * result says which one answered so the UI can be honest about it.
+ */
+ private async fetch(
+ creds: DawarichCreds,
+ userId: number,
+ from: Date,
+ to: Date,
+ offsetMinutes: number,
+ ): Promise {
+ const capabilities = this.dawarich.getCapabilities(userId);
+
+ if (capabilities?.tracks !== false) {
+ try {
+ const { features, truncated } = await this.client.listTracks(creds, from, to);
+ if (features.length > 0) {
+ const days = bucketTracksByDay(features, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY });
+ return {
+ days,
+ source: 'tracks',
+ fetchedAt: new Date().toISOString(),
+ pointCount: countPoints(days),
+ truncated,
+ };
+ }
+ } catch {
+ // Fall through to points: an instance that cannot serve tracks can
+ // usually still serve the points they would have been built from.
+ }
+ }
+
+ const { points, truncated } = await this.client.listPoints(creds, from, to);
+ // Points carry a Unix timestamp and no zone, so the local day has to come
+ // from somewhere. It comes from the caller: the browser knows the offset the
+ // reader is living in, and "which day was that" is their question, not the
+ // server's. Zero — plain UTC — is the answer for a caller that says nothing,
+ // which is what a Date can ever tell us (`toISOString` always ends in `Z`,
+ // so deriving it from the window here only ever produced UTC).
+ const days = bucketPointsByDay(points, offsetMinutes, { maxPointsPerDay: DAWARICH_TRACK_POINTS_PER_DAY });
+ return {
+ days,
+ source: 'points',
+ fetchedAt: new Date().toISOString(),
+ pointCount: countPoints(days),
+ truncated,
+ };
+ }
+
+ private remember(key: string, value: DawarichTrack): void {
+ if (this.cache.size >= DawarichTracksService.MAX_ENTRIES) {
+ const oldest = this.cache.keys().next().value;
+ if (oldest !== undefined) this.cache.delete(oldest);
+ }
+ this.cache.set(key, { at: Date.now(), value });
+ }
+
+ /** Drop everything cached for one user — used when a connection changes. */
+ forget(userId: number): void {
+ for (const key of [...this.cache.keys()]) {
+ if (key.startsWith(`${userId}:`)) this.cache.delete(key);
+ }
+ }
+}
+
+function emptyTrack(): DawarichTrack {
+ return {
+ days: [],
+ source: 'tracks',
+ fetchedAt: new Date().toISOString(),
+ pointCount: 0,
+ truncated: false,
+ };
+}
diff --git a/server/src/nest/integrations/dawarich.client.ts b/server/src/nest/integrations/dawarich.client.ts
new file mode 100644
index 000000000..a5b180b33
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.client.ts
@@ -0,0 +1,426 @@
+import { Injectable } from '@nestjs/common';
+import type { DawarichErrorCode } from '@trek/shared';
+import { safeFetch } from '../../utils/ssrfGuard';
+import { readCappedJson } from '../../utils/cappedFetch';
+
+/**
+ * Thin HTTP client for the Dawarich REST API (github.com/Freika/dawarich).
+ * This is the ONLY place that talks to a user's Dawarich instance.
+ *
+ * Verified against the Dawarich source at 1.14.4 (2026-09-06), not against its
+ * prose documentation — the two disagree in the places that matter here:
+ *
+ * - **Auth**: `Authorization: Bearer ` (or `?api_key=`, which this client
+ * never uses — a key in a query string ends up in every access log between
+ * here and there). `User.find_by(api_key:)` means one key per user and no
+ * scopes: the key reads that user's whole archive. Granular Dawarich tokens
+ * were discussed and have not shipped, so TREK must not depend on them.
+ * - **Version**: every response carries `X-Dawarich-Version`. That is the
+ * instance's own word on what it is, which beats guessing from behaviour.
+ * - `GET /api/v1/health` skips authentication, so it proves reachability and
+ * nothing else. `GET /api/v1/users/me` is the key test.
+ * - `GET /api/v1/visits?start_at&end_at` filters on `started_at` ONLY. A stay
+ * that began before the window is not returned even if it ran well into it,
+ * so callers widen the window rather than trusting its edges. Without a
+ * `page` parameter it returns the whole range in one response; with one, the
+ * page size caps at 500 and the count arrives in `X-Total-Pages`.
+ * - A visit carries **no `updated_at`** and its `place` carries **no country
+ * code**. Change detection is therefore hash-based and the country is
+ * resolved from the coordinates; see dawarich-sync.service.ts.
+ * - Deleting a visit is a soft delete that removes it from the index. Absence
+ * from a full re-read is the only deletion signal there is.
+ * - `GET /api/v1/tracks` answers GeoJSON with one LineString per track and
+ * `start_at`/`end_at` in the properties — a far better overlay source than
+ * raw points. `GET /api/v1/points?slim=true` is the fallback, and its
+ * `latitude`/`longitude` arrive as **strings**.
+ */
+
+const TIMEOUT_MS = 15000;
+
+/** A page of visits is a few hundred small objects; a megabyte is misbehaviour. */
+const MAX_JSON_BYTES = 8 * 1024 * 1024;
+
+/** Dawarich's own ceiling for `per_page` on visits. Asking for more is silently capped. */
+export const VISITS_PAGE_SIZE = 500;
+/** Tracks default to 500 per page and carry whole geometries, so we ask for fewer. */
+export const TRACKS_PAGE_SIZE = 200;
+/** Points cap at 10000 upstream. A day of tracking is a few thousand. */
+export const POINTS_PAGE_SIZE = 5000;
+
+/**
+ * How many pages one call will walk before giving up and saying so.
+ *
+ * A cap rather than a loop: a misconfigured instance answering the same page
+ * forever would otherwise hold a request open until the timeout, and a caller
+ * that is told the result is incomplete can do something about it. Every
+ * paginated method reports `truncated` rather than pretending it saw everything.
+ */
+const MAX_PAGES = 20;
+
+export interface DawarichCreds {
+ /** Instance origin without a trailing /api. */
+ baseUrl: string;
+ apiKey: string;
+ allowInsecureTls: boolean;
+}
+
+/**
+ * A failed Dawarich call, carrying the reason as a code.
+ *
+ * The code is what reaches the user (through i18n on the client) because the
+ * upstream message is English, occasionally quotes the URL back, and on a
+ * misconfigured reverse proxy is an HTML login page. `detail` keeps the
+ * original for the places where a human is debugging their own setup.
+ */
+export class DawarichError extends Error {
+ readonly code: DawarichErrorCode;
+ readonly status?: number;
+ readonly detail?: string;
+
+ constructor(code: DawarichErrorCode, message: string, status?: number, detail?: string) {
+ super(message);
+ this.name = 'DawarichError';
+ this.code = code;
+ this.status = status;
+ this.detail = detail;
+ }
+}
+
+/** A visit exactly as `Api::VisitSerializer` emits it. Nothing is assumed optional-but-present. */
+export interface DawarichVisitRaw {
+ id: number | string;
+ area_id: number | null;
+ user_id?: number;
+ started_at: string;
+ ended_at: string;
+ /** Seconds in current builds; TREK normalises rather than trusting the unit. */
+ duration: number | string | null;
+ name: string | null;
+ status: string | null;
+ confidence: number | string | null;
+ confidence_band: string | null;
+ place: {
+ latitude: number | string | null;
+ longitude: number | string | null;
+ id: number | null;
+ /** Requested upstream, absent at 1.14.4. Read defensively so it lights up on its own. */
+ country_code?: string | null;
+ } | null;
+ /** Requested upstream, absent at 1.14.4. */
+ updated_at?: string | null;
+}
+
+/** One GeoJSON feature from `GET /api/v1/tracks`. */
+export interface DawarichTrackFeature {
+ type?: string;
+ geometry: { type?: string; coordinates?: unknown } | null;
+ properties: {
+ id?: number | string;
+ start_at?: string;
+ end_at?: string;
+ distance?: number | null;
+ duration?: number | null;
+ dominant_mode?: string | null;
+ } | null;
+}
+
+/** One entry of `GET /api/v1/points?slim=true`. lat/lng are strings upstream. */
+export interface DawarichSlimPoint {
+ id?: number;
+ latitude: string | number;
+ longitude: string | number;
+ /** Unix seconds. */
+ timestamp: number;
+ country_name?: string | null;
+}
+
+/** One country of `GET /api/v1/countries/visited_cities`. */
+export interface DawarichVisitedCountry {
+ country: string;
+ cities: Array<{
+ city: string;
+ points?: number;
+ /** Unix seconds of the last point in that city. */
+ timestamp?: number;
+ /** Minutes. */
+ stayed_for?: number;
+ }>;
+}
+
+/** One stay near a coordinate from `GET /api/v1/locations`. */
+export interface DawarichLocationVisit {
+ timestamp: number;
+ date?: string;
+ distance_meters?: number;
+ points_count?: number;
+ visit_details?: {
+ start_time?: string;
+ end_time?: string;
+ duration_minutes?: number;
+ city?: string | null;
+ country?: string | null;
+ } | null;
+}
+
+export interface DawarichProbe {
+ /** From `X-Dawarich-Version`, null on an instance too old to send it. */
+ version: string | null;
+}
+
+/**
+ * `https://host/extra` → `https://host/extra/api/v1`, tolerating what people
+ * actually paste: a trailing slash, a trailing `/api`, or the full `/api/v1`.
+ * Without this, half the support load is `/api/api/v1` 404s.
+ */
+function apiBase(baseUrl: string): string {
+ const origin = baseUrl
+ .trim()
+ .replace(/(?(
+ creds: DawarichCreds,
+ path: string,
+ query: Record,
+ ): Promise<{ data: T; headers: Headers }> {
+ const url = new URL(apiBase(creds.baseUrl) + path);
+ for (const [key, value] of Object.entries(query)) {
+ if (value !== undefined) url.searchParams.set(key, value);
+ }
+
+ let response: Response;
+ try {
+ response = await safeFetch(
+ url.toString(),
+ {
+ method: 'GET',
+ headers: {
+ Authorization: `Bearer ${creds.apiKey}`,
+ Accept: 'application/json',
+ },
+ signal: AbortSignal.timeout(TIMEOUT_MS) as AbortSignal,
+ },
+ { rejectUnauthorized: !creds.allowInsecureTls },
+ );
+ } catch (err: unknown) {
+ throw new DawarichError(
+ 'unreachable',
+ 'Could not reach Dawarich',
+ undefined,
+ err instanceof Error ? err.message : undefined,
+ );
+ }
+
+ if (!response.ok) {
+ throw new DawarichError(
+ classify(response.status),
+ `Dawarich answered HTTP ${response.status}`,
+ response.status,
+ );
+ }
+
+ // Read through the cap rather than calling .json(): Dawarich answers chunked
+ // over large ranges, so there is no content-length to check first, and an
+ // auth proxy in front of it answers with an HTML login page that would
+ // otherwise surface as "Unexpected token '<'".
+ const data = await readCappedJson(response, MAX_JSON_BYTES);
+ if (data === undefined) {
+ throw new DawarichError(
+ 'invalid_response',
+ 'Dawarich returned something that is not JSON. Check the URL points at Dawarich itself and that it is reachable without a separate login.',
+ );
+ }
+ return { data, headers: response.headers };
+ }
+
+ /**
+ * Authenticate and read the version banner.
+ *
+ * `/users/me` rather than `/health`, because health skips authentication
+ * upstream: a wrong key would pass it, and "connected" would mean nothing.
+ */
+ async probe(creds: DawarichCreds): Promise {
+ const { headers } = await this.get(creds, '/users/me', {});
+ return { version: headers.get('x-dawarich-version') };
+ }
+
+ /**
+ * Visits whose stay STARTED inside the window.
+ *
+ * Paginated explicitly even though the endpoint returns everything without a
+ * `page` parameter: a multi-year archive in one response is exactly the shape
+ * the size cap exists to refuse, and the caller would get an error instead of
+ * a long answer.
+ */
+ async listVisits(
+ creds: DawarichCreds,
+ from: Date,
+ to: Date,
+ ): Promise<{ visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }> {
+ const visits: DawarichVisitRaw[] = [];
+ let version: string | null = null;
+ let page = 1;
+ let truncated = false;
+
+ for (; page <= MAX_PAGES; page++) {
+ const { data, headers } = await this.get(creds, '/visits', {
+ start_at: isoUtc(from),
+ end_at: isoUtc(to),
+ page: String(page),
+ per_page: String(VISITS_PAGE_SIZE),
+ });
+ version ??= headers.get('x-dawarich-version');
+ if (!Array.isArray(data)) {
+ throw new DawarichError('invalid_response', 'Dawarich returned a visits payload that is not a list');
+ }
+ visits.push(...data);
+ const totalPages = Number(headers.get('x-total-pages') ?? '1');
+ if (!Number.isFinite(totalPages) || page >= totalPages) break;
+ if (page === MAX_PAGES) truncated = true;
+ }
+
+ return { visits, truncated, version };
+ }
+
+ /**
+ * Pre-segmented recorded routes overlapping the window, as GeoJSON features.
+ *
+ * Unlike visits, the upstream filter is a real overlap test
+ * (`end_at >= from AND start_at <= to`), so a drive that crosses the window's
+ * edge is included rather than dropped.
+ */
+ async listTracks(
+ creds: DawarichCreds,
+ from: Date,
+ to: Date,
+ ): Promise<{ features: DawarichTrackFeature[]; truncated: boolean }> {
+ const features: DawarichTrackFeature[] = [];
+ let truncated = false;
+
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const { data, headers } = await this.get<{ features?: DawarichTrackFeature[] }>(creds, '/tracks', {
+ start_at: isoUtc(from),
+ end_at: isoUtc(to),
+ page: String(page),
+ per_page: String(TRACKS_PAGE_SIZE),
+ });
+ const batch = Array.isArray(data?.features) ? data.features : [];
+ features.push(...batch);
+ const totalPages = Number(headers.get('x-total-pages') ?? '1');
+ if (!Number.isFinite(totalPages) || page >= totalPages) break;
+ if (page === MAX_PAGES) truncated = true;
+ }
+
+ return { features, truncated };
+ }
+
+ /**
+ * Raw recorded points, oldest first — the fallback when an instance has not
+ * generated tracks (they are produced by a background job, so a fresh import
+ * has points long before it has tracks).
+ */
+ async listPoints(
+ creds: DawarichCreds,
+ from: Date,
+ to: Date,
+ ): Promise<{ points: DawarichSlimPoint[]; truncated: boolean }> {
+ const points: DawarichSlimPoint[] = [];
+ let truncated = false;
+
+ for (let page = 1; page <= MAX_PAGES; page++) {
+ const { data, headers } = await this.get(creds, '/points', {
+ start_at: unixSeconds(from),
+ end_at: unixSeconds(to),
+ order: 'asc',
+ slim: 'true',
+ page: String(page),
+ per_page: String(POINTS_PAGE_SIZE),
+ });
+ if (!Array.isArray(data)) {
+ throw new DawarichError('invalid_response', 'Dawarich returned a points payload that is not a list');
+ }
+ points.push(...data);
+ const totalPages = Number(headers.get('x-total-pages') ?? '1');
+ if (!Number.isFinite(totalPages) || page >= totalPages) break;
+ if (page === MAX_PAGES) truncated = true;
+ }
+
+ return { points, truncated };
+ }
+
+ /** Countries and cities the recordings put the user in, for the Atlas. */
+ async listVisitedCities(
+ creds: DawarichCreds,
+ from: Date,
+ to: Date,
+ ): Promise {
+ const { data } = await this.get<{ data?: DawarichVisitedCountry[] }>(creds, '/countries/visited_cities', {
+ start_at: unixSeconds(from),
+ end_at: unixSeconds(to),
+ });
+ return Array.isArray(data?.data) ? data.data : [];
+ }
+
+ /**
+ * "Was I ever here, and for how long?" for one coordinate.
+ *
+ * `radius_override` is passed explicitly: without it Dawarich picks a radius
+ * from the place TYPE, and a coordinate search has no type, so it falls back
+ * to 500 m — wide enough to call a drive past a landmark a visit.
+ */
+ async findVisitsNear(
+ creds: DawarichCreds,
+ lat: number,
+ lng: number,
+ radiusMeters: number,
+ limit: number,
+ ): Promise {
+ const { data } = await this.get<{
+ locations?: Array<{ visits?: DawarichLocationVisit[] }>;
+ }>(creds, '/locations', {
+ lat: String(lat),
+ lon: String(lng),
+ radius_override: String(Math.round(radiusMeters)),
+ limit: String(limit),
+ });
+ const first = Array.isArray(data?.locations) ? data.locations[0] : undefined;
+ return Array.isArray(first?.visits) ? first.visits : [];
+ }
+}
diff --git a/server/src/nest/integrations/dawarich.controller.ts b/server/src/nest/integrations/dawarich.controller.ts
new file mode 100644
index 000000000..e591cdc11
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.controller.ts
@@ -0,0 +1,351 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpException,
+ Param,
+ Post,
+ Put,
+ Query,
+ Req,
+ UseGuards,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import type { User } from '../../types';
+import { JwtAuthGuard } from '../auth/jwt-auth.guard';
+import { CurrentUser } from '../auth/current-user.decorator';
+import { AddonGuard } from '../addons/addon.guard';
+import { RequireAddon } from '../addons/require-addon.decorator';
+import { ADDON_IDS } from '../../addons';
+import { getClientIp } from '../audit/client-ip';
+import { DawarichService } from './dawarich.service';
+import { DawarichSyncService } from './dawarich-sync.service';
+import { DawarichSuggestionsService, AcceptError } from './dawarich-suggestions.service';
+import { DawarichTracksService } from './dawarich-tracks.service';
+import { DawarichError } from './dawarich.client';
+import {
+ DawarichAcceptDto,
+ DawarichAtlasAcceptDto,
+ DawarichBucketConfirmDto,
+ DawarichSettingsDto,
+ DawarichSuggestionStateDto,
+} from './dawarich.dto';
+
+/**
+ * `/api/integrations/dawarich` — the per-user Dawarich connection and everything
+ * it produces (#2279).
+ *
+ * Two shapes of answer, deliberately different:
+ *
+ * - **Connection routes** (`status`, `test`) answer 200 even when the instance
+ * is unreachable, with the reason in the body. A form that is showing someone
+ * their own typo needs a field to render, not an exception.
+ * - **Data routes** answer the status the failure deserves, because a caller
+ * that asked for suggestions and got an empty list would take it as "there
+ * are none".
+ *
+ * The whole group is gated on the `dawarich` addon, with `AddonGuard` ahead of
+ * `JwtAuthGuard` — the order every addon-gated controller in TREK uses, so that
+ * a switched-off addon answers the same 404 to everyone rather than 401 to some
+ * callers and 404 to others. It does mean an unauthenticated caller can learn
+ * whether the addon is on; that is true of every addon here and is not a secret
+ * worth a second guard order for.
+ */
+@Controller('api/integrations/dawarich')
+@UseGuards(AddonGuard, JwtAuthGuard)
+@RequireAddon(ADDON_IDS.DAWARICH, 'Dawarich')
+export class DawarichController {
+ constructor(
+ private readonly dawarich: DawarichService,
+ private readonly sync: DawarichSyncService,
+ private readonly suggestions: DawarichSuggestionsService,
+ private readonly tracks: DawarichTracksService,
+ ) {}
+
+ // ── Connection ─────────────────────────────────────────────────────────────
+
+ @Get('settings')
+ getSettings(@CurrentUser() user: User) {
+ return this.dawarich.getConnection(user.id);
+ }
+
+ @Put('settings')
+ async putSettings(
+ @CurrentUser() user: User,
+ @Body() body: DawarichSettingsDto,
+ @Req() req: Request,
+ ) {
+ const result = await this.dawarich.saveSettings(
+ user.id,
+ body.url,
+ body.apiKey,
+ !!body.allowInsecureTls,
+ body.syncEnabled !== false,
+ getClientIp(req),
+ );
+ if (!result.success) {
+ throw new HttpException({ error: result.error }, 400);
+ }
+ // A changed address or key means every cached line was drawn from somewhere
+ // else. Keeping it would show the previous instance's route under the new
+ // connection's name.
+ this.tracks.forget(user.id);
+ return result.warning ? { success: true, warning: result.warning } : { success: true };
+ }
+
+ @Delete('settings')
+ @HttpCode(200)
+ disconnect(@CurrentUser() user: User, @Req() req: Request) {
+ this.dawarich.disconnect(user.id, getClientIp(req));
+ this.tracks.forget(user.id);
+ return { success: true };
+ }
+
+ @Post('test')
+ @HttpCode(200)
+ test(@CurrentUser() user: User, @Body() body: DawarichSettingsDto) {
+ return this.dawarich.testConnection(user.id, body.url, body.apiKey, !!body.allowInsecureTls);
+ }
+
+ /** Pull now, rather than waiting for the next tick. */
+ @Post('sync')
+ @HttpCode(200)
+ async syncNow(@CurrentUser() user: User) {
+ return this.sync.syncUser(user.id);
+ }
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+
+ @Get('suggestions')
+ listSuggestions(
+ @CurrentUser() user: User,
+ @Query('tripId') tripId?: string,
+ @Query('state') state?: string,
+ ) {
+ return this.suggestions.list(user.id, {
+ tripId: parseOptionalId(tripId, 'tripId'),
+ state: parseState(state),
+ });
+ }
+
+ @Post('suggestions/:id/accept')
+ @HttpCode(200)
+ acceptSuggestion(
+ @CurrentUser() user: User,
+ @Param('id') id: string,
+ @Body() body: DawarichAcceptDto,
+ @Req() req: Request,
+ ) {
+ return this.guard(() =>
+ this.suggestions.accept(user.id, parseId(id), body, socketId(req)),
+ );
+ }
+
+ @Put('suggestions/:id/state')
+ @HttpCode(200)
+ setSuggestionState(
+ @CurrentUser() user: User,
+ @Param('id') id: string,
+ @Body() body: DawarichSuggestionStateDto,
+ ) {
+ const updated = this.suggestions.setState(user.id, parseId(id), body.state);
+ if (!updated) throw new HttpException({ error: 'Suggestion not found' }, 404);
+ return updated;
+ }
+
+ // ── Bucket list ────────────────────────────────────────────────────────────
+
+ /** Ask the recordings whether the caller ever reached their own wishes. */
+ @Post('bucket-list/scan')
+ @HttpCode(200)
+ async scanBucketList(@CurrentUser() user: User) {
+ return this.guardAsync(() => this.suggestions.scanBucketList(user.id));
+ }
+
+ @Post('bucket-list/confirm')
+ @HttpCode(200)
+ confirmBucketVisits(@CurrentUser() user: User, @Body() body: DawarichBucketConfirmDto) {
+ return { updated: this.suggestions.confirmBucketVisits(user.id, body.itemIds, body.visitedAt) };
+ }
+
+ @Delete('bucket-list/:itemId/visit')
+ @HttpCode(200)
+ clearBucketVisit(@CurrentUser() user: User, @Param('itemId') itemId: string) {
+ const cleared = this.suggestions.clearBucketVisit(user.id, parseId(itemId));
+ if (!cleared) throw new HttpException({ error: 'Bucket-list entry not found' }, 404);
+ return { success: true };
+ }
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+
+ @Get('atlas/suggestions')
+ async atlasSuggestions(
+ @CurrentUser() user: User,
+ @Query('from') from?: string,
+ @Query('to') to?: string,
+ ) {
+ const window = parseWindow(from, to);
+ return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to));
+ }
+
+ @Post('atlas/accept')
+ @HttpCode(200)
+ acceptAtlasCountries(@CurrentUser() user: User, @Body() body: DawarichAtlasAcceptDto) {
+ return { marked: this.suggestions.acceptAtlasCountries(user.id, body.countryCodes) };
+ }
+
+ // ── Track overlay ──────────────────────────────────────────────────────────
+
+ /**
+ * The recorded route for a trip. Fetched from Dawarich per request and not
+ * stored — see DawarichTracksService.
+ */
+ @Get('trips/:tripId/track')
+ async tripTrack(
+ @CurrentUser() user: User,
+ @Param('tripId') tripId: string,
+ @Query('from') from?: string,
+ @Query('to') to?: string,
+ @Query('offset') offset?: string,
+ ) {
+ const track = await this.guardAsync(() =>
+ this.tracks.forTrip(user.id, parseId(tripId), parseDate(from), parseDate(to), parseOffset(offset)),
+ );
+ if (!track) throw new HttpException({ error: 'Trip not found' }, 404);
+ return track;
+ }
+
+ /** The recorded route for an explicit range — what the journal asks for. */
+ @Get('track')
+ async windowTrack(
+ @CurrentUser() user: User,
+ @Query('from') from?: string,
+ @Query('to') to?: string,
+ @Query('offset') offset?: string,
+ ) {
+ const window = parseWindow(from, to);
+ return this.guardAsync(() =>
+ this.tracks.forWindow(
+ user.id,
+ window.from.toISOString(),
+ window.to.toISOString(),
+ parseOffset(offset),
+ ),
+ );
+ }
+
+ // ── Error shaping ──────────────────────────────────────────────────────────
+
+ /**
+ * One place where a domain error becomes an HTTP one.
+ *
+ * `DawarichError` keeps its own code in the body: the client renders these
+ * through i18n, and the upstream sentence would otherwise reach a German
+ * install in English. 502 rather than 500, because the failure is a server
+ * TREK called, not TREK itself.
+ */
+ private guard(run: () => T): T {
+ try {
+ return run();
+ } catch (err) {
+ throw toHttp(err);
+ }
+ }
+
+ private async guardAsync(run: () => Promise): Promise {
+ try {
+ return await run();
+ } catch (err) {
+ throw toHttp(err);
+ }
+ }
+}
+
+function toHttp(err: unknown): HttpException {
+ if (err instanceof AcceptError) {
+ return new HttpException({ error: err.message, code: err.code }, err.status);
+ }
+ if (err instanceof DawarichError) {
+ return new HttpException(
+ { error: err.message, code: err.code, ...(err.detail ? { detail: err.detail } : {}) },
+ err.code === 'unauthorized' || err.code === 'forbidden' ? 400 : 502,
+ );
+ }
+ if (err instanceof HttpException) return err;
+ return new HttpException({ error: 'Dawarich request failed', code: 'server_error' }, 502);
+}
+
+/** Ids are integers. `parseInt` would turn "12abc" into 12 and log a request nobody made. */
+function parseId(raw: string): number {
+ if (!/^\d+$/.test(raw)) throw new HttpException({ error: 'Invalid id' }, 400);
+ const id = Number(raw);
+ if (!Number.isSafeInteger(id) || id <= 0) throw new HttpException({ error: 'Invalid id' }, 400);
+ return id;
+}
+
+function parseOptionalId(raw: string | undefined, label: string): number | undefined {
+ if (raw === undefined || raw === '') return undefined;
+ if (!/^\d+$/.test(raw)) throw new HttpException({ error: `Invalid ${label}` }, 400);
+ return Number(raw);
+}
+
+/** An unknown state is a 400 rather than a silently unfiltered list. */
+function parseState(raw: string | undefined): string | undefined {
+ if (raw === undefined || raw === '') return undefined;
+ if (raw !== 'new' && raw !== 'accepted' && raw !== 'dismissed') {
+ throw new HttpException({ error: 'state must be one of: new, accepted, dismissed' }, 400);
+ }
+ return raw;
+}
+
+/**
+ * The caller's UTC offset in minutes, as `Date.getTimezoneOffset()` negated —
+ * +120 for Berlin in summer.
+ *
+ * Points from Dawarich are bare instants, so "which day was that" can only be
+ * answered in somebody's timezone, and the browser is the only party that knows
+ * which one. Absent or unreadable means UTC, which is what the endpoint did
+ * before it asked. Bounded to the real range of world offsets (-12h..+14h) so a
+ * junk value cannot shift a day by a week.
+ */
+function parseOffset(raw: string | undefined): number {
+ if (raw === undefined || raw === '') return 0;
+ const value = Number(raw);
+ if (!Number.isFinite(value)) return 0;
+ return Math.max(-720, Math.min(840, Math.trunc(value)));
+}
+
+function parseDate(raw: string | undefined): string | undefined {
+ if (raw === undefined || raw === '') return undefined;
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
+ throw new HttpException({ error: 'Dates must be ISO, YYYY-MM-DD' }, 400);
+ }
+ return raw;
+}
+
+/**
+ * An explicit instant window. Both ends are required together: a half-open
+ * range would silently become "everything since the epoch" on one side, which
+ * is the request that makes an instance time out.
+ */
+function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
+ const start = from ? new Date(from) : null;
+ const end = to ? new Date(to) : null;
+ if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
+ throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400);
+ }
+ if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400);
+ const MAX_DAYS = 400;
+ if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) {
+ throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400);
+ }
+ return { from: start, to: end };
+}
+
+/** The originating client's socket id, so a broadcast does not echo back to it. */
+function socketId(req: Request): string | undefined {
+ const header = req.headers['x-socket-id'];
+ return typeof header === 'string' && header ? header : undefined;
+}
diff --git a/server/src/nest/integrations/dawarich.dto.ts b/server/src/nest/integrations/dawarich.dto.ts
new file mode 100644
index 000000000..00b802086
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.dto.ts
@@ -0,0 +1,19 @@
+/**
+ * Server-side createZodDto wrappers over the @trek/shared Dawarich contracts,
+ * so the global ZodValidationPipe (APP_PIPE) validates bodies by metatype —
+ * the shared Zod schemas stay the single source of truth.
+ */
+import { createZodDto } from 'nestjs-zod';
+import {
+ dawarichAcceptSchema,
+ dawarichAtlasAcceptSchema,
+ dawarichBucketConfirmSchema,
+ dawarichSettingsSchema,
+ dawarichSuggestionStateSchemaBody,
+} from '@trek/shared';
+
+export class DawarichSettingsDto extends createZodDto(dawarichSettingsSchema) {}
+export class DawarichAcceptDto extends createZodDto(dawarichAcceptSchema) {}
+export class DawarichSuggestionStateDto extends createZodDto(dawarichSuggestionStateSchemaBody) {}
+export class DawarichBucketConfirmDto extends createZodDto(dawarichBucketConfirmSchema) {}
+export class DawarichAtlasAcceptDto extends createZodDto(dawarichAtlasAcceptSchema) {}
diff --git a/server/src/nest/integrations/dawarich.helpers.ts b/server/src/nest/integrations/dawarich.helpers.ts
new file mode 100644
index 000000000..0285e2523
Binary files /dev/null and b/server/src/nest/integrations/dawarich.helpers.ts differ
diff --git a/server/src/nest/integrations/dawarich.mcp.ts b/server/src/nest/integrations/dawarich.mcp.ts
new file mode 100644
index 000000000..c77b2b22f
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.mcp.ts
@@ -0,0 +1,240 @@
+import {
+ McpController, Tool, type McpContext,
+ TOOL_ANNOTATIONS_READONLY,
+ TOOL_ANNOTATIONS_WRITE,
+ TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ demoDenied, errorResult, ok,
+} from '../../nest-mcp';
+import { z } from 'zod';
+import { ADDON_IDS } from '../../addons';
+import { addonGate } from '../addons/addon-gate';
+import { AddonsService } from '../addons/addons.service';
+import { AuthService } from '../auth/auth.service';
+import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service';
+import { DawarichTracksService } from './dawarich-tracks.service';
+
+/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */
+const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH);
+
+/**
+ * A stay list is short by nature — a day of travel produces a handful — but a
+ * year of them is not, and an assistant paying for the whole backlog on every
+ * turn is the failure mode. The browser can afford the full list because a
+ * human scrolls it.
+ */
+const DEFAULT_LIMIT = 50;
+const MAX_LIMIT = 200;
+
+/**
+ * Dawarich MCP surface — the review half of the integration, mirroring
+ * `/api/integrations/dawarich/suggestions` and the track overlay.
+ *
+ * The scopes are chosen by **what each tool writes**, not by what domain the
+ * data came from, which is why accepting is three tools rather than one with a
+ * `target` parameter: creating a place is `places:write`, writing a journal
+ * entry is `journey:write`, and ticking off a wish is `atlas:write`. A single
+ * tool would have to claim the union of all three, and a caller that only
+ * wanted to fill in a journal would be holding a credential that can also edit
+ * an itinerary.
+ *
+ * Reading the suggestions themselves rides `journey:read`. They are candidate
+ * journal entries before they are anything else, and that is the narrowest
+ * existing scope that honestly covers "stays this person recorded".
+ *
+ * The connection routes (settings, test, disconnect) are deliberately absent:
+ * they take an API key for somebody's complete location history, and that is
+ * exactly what no assistant scope should be able to read or replace.
+ */
+@McpController()
+export class DawarichMcp {
+ constructor(
+ private readonly suggestions: DawarichSuggestionsService,
+ private readonly tracks: DawarichTracksService,
+ private readonly auth: AuthService,
+ readonly addons: AddonsService,
+ ) {}
+
+ @Tool({
+ name: 'list_dawarich_suggestions',
+ description:
+ "List the stays TREK pulled from the caller's connected Dawarich instance and is holding for review: where they were, when they arrived and left, how long they stayed, and which trip the stay falls into. Each entry carries the id the accept tools take. Use it to answer what someone actually did on a trip, or to fill a travel journal from what was recorded rather than from memory. Nothing here has been added to a trip yet — accepting is a separate, explicit step.",
+ inputSchema: {
+ tripId: z.number().int().positive().optional().describe('Only stays that fall inside this trip'),
+ state: z.enum(['new', 'accepted', 'dismissed']).optional()
+ .describe('Default: every state. "new" is the review backlog.'),
+ limit: z.number().int().min(1).max(MAX_LIMIT).optional()
+ .describe(`Maximum stays to return, most recent first (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT})`),
+ },
+ // Reads TREK's own table; the fetch from the remote instance happens on a cron.
+ annotations: TOOL_ANNOTATIONS_READONLY,
+ access: { group: 'journey', mode: 'read' },
+ when: dawarichAddonOn,
+ })
+ listSuggestions(
+ { tripId, state, limit }: { tripId?: number; state?: 'new' | 'accepted' | 'dismissed'; limit?: number },
+ ctx: McpContext,
+ ) {
+ const all = this.suggestions.list(ctx.userId, { tripId, state });
+ const cap = limit ?? DEFAULT_LIMIT;
+ const page = all.suggestions.slice(0, cap);
+ return ok({
+ suggestions: page,
+ total: all.suggestions.length,
+ truncated: all.suggestions.length > page.length,
+ connected: all.connected,
+ lastSyncAt: all.lastSyncAt,
+ lastSyncState: all.lastSyncState,
+ });
+ }
+
+ @Tool({
+ name: 'accept_dawarich_suggestion_as_place',
+ description:
+ 'Turn a reviewed Dawarich stay into a place on a trip, optionally pinned to one of its days. Call list_dawarich_suggestions first for the id. Correct the name or the coordinates in the same call if the detector got them wrong — that is what the review step is for. Use this when the stay is somewhere worth having on the itinerary; use accept_dawarich_suggestion_as_journal_entry when it is a moment worth writing about instead.',
+ inputSchema: {
+ suggestionId: z.number().int().positive(),
+ tripId: z.number().int().positive().optional().describe('Defaults to the trip the stay fell into'),
+ dayId: z.number().int().positive().optional().describe('Pin it to this day; omitted leaves it unplanned'),
+ name: z.string().trim().min(1).max(255).optional(),
+ notes: z.string().max(5000).optional(),
+ lat: z.number().min(-90).max(90).optional(),
+ lng: z.number().min(-180).max(180).optional(),
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ access: { group: 'places', mode: 'write' },
+ when: dawarichAddonOn,
+ })
+ acceptAsPlace(
+ args: { suggestionId: number; tripId?: number; dayId?: number; name?: string; notes?: string; lat?: number; lng?: number },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ const { suggestionId, ...rest } = args;
+ return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'place', ...rest }));
+ }
+
+ @Tool({
+ name: 'accept_dawarich_suggestion_as_journal_entry',
+ description:
+ "Turn a reviewed Dawarich stay into a dated entry in a travel journal. Call list_dawarich_suggestions first for the id. The stay's own date, arrival time, name and coordinates prefill the entry; pass a title or a story to write it properly rather than leaving the detector's label. Use this to fill in a journal after a trip from what was actually recorded.",
+ inputSchema: {
+ suggestionId: z.number().int().positive(),
+ journalId: z.number().int().positive().describe('The journey the entry is added to'),
+ name: z.string().trim().min(1).max(255).optional().describe('Entry title; defaults to the stay name'),
+ notes: z.string().max(5000).optional().describe('The story text'),
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ time: z.string().regex(/^\d{2}:\d{2}$/).optional(),
+ },
+ annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
+ access: { group: 'journey', mode: 'write' },
+ when: dawarichAddonOn,
+ })
+ acceptAsJournalEntry(
+ args: { suggestionId: number; journalId: number; name?: string; notes?: string; date?: string; time?: string },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ const { suggestionId, ...rest } = args;
+ return this.run(() => this.suggestions.accept(ctx.userId, suggestionId, { target: 'journal', ...rest }));
+ }
+
+ @Tool({
+ name: 'mark_bucket_list_item_visited_from_dawarich',
+ description:
+ "Tick a bucket-list wish off because a recorded stay proves the caller got there. Call list_dawarich_suggestions first: a stay that sits on top of a wish already carries its id as matchedBucketListItemId. Use it when someone asks which of the places they wanted to see they have actually reached.",
+ inputSchema: {
+ suggestionId: z.number().int().positive(),
+ bucketListItemId: z.number().int().positive().optional()
+ .describe('Defaults to the wish the stay was matched to'),
+ },
+ annotations: TOOL_ANNOTATIONS_WRITE,
+ access: { group: 'atlas', mode: 'write' },
+ when: dawarichAddonOn,
+ })
+ markBucketVisited(
+ { suggestionId, bucketListItemId }: { suggestionId: number; bucketListItemId?: number },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ return this.run(() =>
+ this.suggestions.accept(ctx.userId, suggestionId, { target: 'bucket_list', bucketListItemId }),
+ );
+ }
+
+ @Tool({
+ name: 'dismiss_dawarich_suggestion',
+ description:
+ 'Take a Dawarich stay out of the review list without adding it anywhere, or put a dismissed one back. Use it for the stays a detector produces that are not places anyone went — a traffic jam, a car park, the office.',
+ inputSchema: {
+ suggestionId: z.number().int().positive(),
+ state: z.enum(['dismissed', 'new']).optional().describe('Default: dismissed'),
+ },
+ annotations: TOOL_ANNOTATIONS_WRITE,
+ access: { group: 'journey', mode: 'write' },
+ when: dawarichAddonOn,
+ })
+ dismiss(
+ { suggestionId, state }: { suggestionId: number; state?: 'dismissed' | 'new' },
+ ctx: McpContext,
+ ) {
+ if (this.auth.isDemoUser(ctx.userId)) return demoDenied();
+ const updated = this.suggestions.setState(ctx.userId, suggestionId, state ?? 'dismissed');
+ if (!updated) return errorResult('Suggestion not found');
+ return ok({ suggestion: updated });
+ }
+
+ @Tool({
+ name: 'get_dawarich_trip_track',
+ description:
+ "Fetch the route actually recorded during a trip, grouped by local day, straight from the caller's Dawarich instance. Each day carries its segments with start and end times and, where Dawarich classified it, how it was travelled. Use it to answer what route someone really took, or how a day's movement compares with what was planned. Nothing is stored in TREK — this is a live read of the recording.",
+ inputSchema: {
+ tripId: z.number().int().positive(),
+ from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this first day'),
+ to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Narrow to this last day'),
+ },
+ // Reaches a remote instance rather than TREK's own database.
+ annotations: { ...TOOL_ANNOTATIONS_READONLY, openWorldHint: true },
+ access: { group: 'journey', mode: 'read' },
+ when: dawarichAddonOn,
+ })
+ async tripTrack(
+ { tripId, from, to }: { tripId: number; from?: string; to?: string },
+ ctx: McpContext,
+ ) {
+ let track;
+ try {
+ track = await this.tracks.forTrip(ctx.userId, tripId, from, to);
+ } catch (err) {
+ return errorResult(err instanceof Error ? err.message : 'Could not read the Dawarich recording');
+ }
+ if (!track) return errorResult('Trip not found');
+ // The geometry is the whole payload and a month of it is enormous in a tool
+ // result, so the summary goes out and the lines stay on the map.
+ return ok({
+ source: track.source,
+ fetchedAt: track.fetchedAt,
+ truncated: track.truncated,
+ pointCount: track.pointCount,
+ days: track.days.map((day) => ({
+ date: day.date,
+ segments: day.segments.map((segment) => ({
+ mode: segment.mode,
+ startedAt: segment.startedAt,
+ endedAt: segment.endedAt,
+ distanceMeters: segment.distanceMeters,
+ points: segment.points.length,
+ })),
+ })),
+ });
+ }
+
+ /** The MCP echo of the controller's error shaping: a refusal, not an exception. */
+ private run(action: () => T) {
+ try {
+ return ok(action() as object);
+ } catch (err) {
+ if (err instanceof AcceptError) return errorResult(err.message);
+ throw err;
+ }
+ }
+}
diff --git a/server/src/nest/integrations/dawarich.module.ts b/server/src/nest/integrations/dawarich.module.ts
new file mode 100644
index 000000000..c416defab
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.module.ts
@@ -0,0 +1,66 @@
+import { Module } from '@nestjs/common';
+import { AddonsModule } from '../addons/addons.module';
+import { AuditModule } from '../audit/audit.module';
+import { AuthModule } from '../auth/auth.module';
+import { AtlasModule } from '../atlas/atlas.module';
+import { PermissionsModule } from '../permissions/permissions.module';
+import { PlacesModule } from '../places/places.module';
+import { AssignmentsDomainModule } from '../assignments/assignments-domain.module';
+import { JourneyDomainModule } from '../journey/journey-domain.module';
+import { SchedulingModule } from '../scheduling/scheduling.module';
+import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
+import { DawarichClient } from './dawarich.client';
+import { DawarichController } from './dawarich.controller';
+import { DawarichMcp } from './dawarich.mcp';
+import { DawarichService } from './dawarich.service';
+import { DawarichSuggestionsService } from './dawarich-suggestions.service';
+import { DawarichSyncJob } from './dawarich-sync.job';
+import { DawarichSyncService } from './dawarich-sync.service';
+import { DawarichTracksService } from './dawarich-tracks.service';
+
+/**
+ * The Dawarich integration (#2279): a per-user connection to a self-hosted
+ * location-history instance, the visits it reports, and the recorded route
+ * drawn over a trip.
+ *
+ * Everything hangs off `/api/integrations/dawarich`, so there is no cycle to
+ * dodge and no split into a core module the way AirTrail needed — nothing in
+ * TREK injects Dawarich, Dawarich injects TREK. The direction of that arrow is
+ * the architecture: this module reads other domains' services to *create* what
+ * a user accepted, and never the other way round.
+ *
+ * The leaf modules are picked deliberately. `AssignmentsDomainModule` rather
+ * than `AssignmentsModule`, and `JourneyDomainModule` rather than
+ * `JourneyModule`, so accepting a suggestion does not drag two controller
+ * stacks and both photo providers into this graph.
+ */
+@Module({
+ imports: [
+ AddonsModule,
+ AuditModule,
+ // The MCP tools ask AuthService whether the caller is the demo user, the
+ // same gate every other write tool carries.
+ AuthModule,
+ AtlasModule,
+ // Accepting a stay writes a place and a day assignment, so it asks the same
+ // permissions the planner asks before doing either.
+ PermissionsModule,
+ PlacesModule,
+ AssignmentsDomainModule,
+ JourneyDomainModule,
+ SchedulingModule,
+ McpSharedModule,
+ ],
+ controllers: [DawarichController],
+ providers: [
+ DawarichClient,
+ DawarichService,
+ DawarichSyncService,
+ DawarichSuggestionsService,
+ DawarichTracksService,
+ DawarichSyncJob,
+ DawarichMcp,
+ ],
+ exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService],
+})
+export class DawarichModule {}
diff --git a/server/src/nest/integrations/dawarich.service.ts b/server/src/nest/integrations/dawarich.service.ts
new file mode 100644
index 000000000..20f0ecd30
--- /dev/null
+++ b/server/src/nest/integrations/dawarich.service.ts
@@ -0,0 +1,401 @@
+import { Injectable } from '@nestjs/common';
+import {
+ DAWARICH_KEY_MASK,
+ type DawarichCapabilities,
+ type DawarichConnection,
+ type DawarichStatus,
+ type DawarichSyncState,
+} from '@trek/shared';
+import { DatabaseService } from '../database/database.service';
+import { AuditService } from '../audit/audit.service';
+import { maybe_encrypt_api_key, decrypt_api_key } from '../common/crypto/apiKeyCrypto';
+import { checkSsrf } from '../../utils/ssrfGuard';
+import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
+
+/**
+ * The Dawarich connection: credentials, the probe, and what the connected
+ * instance can actually do.
+ *
+ * Credentials live in `dawarich_connections` rather than in more `users`
+ * columns. AirTrail put its four on `users` and said so out loud — correct while
+ * it was the only integration of that shape, and this is the second one. The
+ * deciding difference is that this connection carries state (last sync, last
+ * error, probed capabilities) that belongs to a sync, not to an identity, and
+ * that a personal location archive is the one credential worth keeping furthest
+ * from the row every other feature joins against.
+ *
+ * The key is encrypted at rest and never leaves again: `getConnection` returns a
+ * mask, and there is no route that reads it back.
+ */
+@Injectable()
+export class DawarichService {
+ constructor(
+ private readonly db: DatabaseService,
+ private readonly audit: AuditService,
+ private readonly client: DawarichClient,
+ ) {}
+
+ private readRow(userId: number): ConnRow | undefined {
+ return this.db.get(
+ `SELECT user_id, url, api_key, allow_insecure_tls, sync_enabled,
+ last_sync_at, last_sync_state, last_sync_error, capabilities
+ FROM dawarich_connections WHERE user_id = ?`,
+ userId,
+ );
+ }
+
+ /** Decrypted credentials for an outbound call, or null when nothing is connected. */
+ getCredentials(userId: number): DawarichCreds | null {
+ const row = this.readRow(userId);
+ if (!row?.url || !row?.api_key) return null;
+ const apiKey = decrypt_api_key(row.api_key);
+ if (!apiKey) return null;
+ return { baseUrl: row.url, apiKey, allowInsecureTls: !!row.allow_insecure_tls };
+ }
+
+ /** True when the user has both an address and a key on file. */
+ isConnected(userId: number): boolean {
+ const row = this.readRow(userId);
+ return !!(row?.url && row?.api_key);
+ }
+
+ /** Every user whose connection is usable and whose background sync is on. */
+ listSyncableUserIds(): number[] {
+ const rows = this.db.all<{ user_id: number }>(
+ `SELECT user_id FROM dawarich_connections
+ WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL`,
+ );
+ return rows.map((r) => r.user_id);
+ }
+
+ /** The connection as the settings card shows it. The key is a mask, always. */
+ getConnection(userId: number): DawarichConnection {
+ const row = this.readRow(userId);
+ return {
+ url: row?.url || '',
+ apiKeyMasked: row?.api_key ? DAWARICH_KEY_MASK : '',
+ allowInsecureTls: !!row?.allow_insecure_tls,
+ // A row that does not exist yet is a user who has never opened the card,
+ // and the default there is "poll once it is connected".
+ syncEnabled: row ? !!row.sync_enabled : true,
+ connected: !!(row?.url && row?.api_key),
+ lastSyncAt: row?.last_sync_at ?? null,
+ lastSyncState: normalizeSyncState(row?.last_sync_state),
+ lastSyncError: row?.last_sync_error ?? null,
+ capabilities: parseCapabilities(row?.capabilities),
+ };
+ }
+
+ getCapabilities(userId: number): DawarichCapabilities | null {
+ return parseCapabilities(this.readRow(userId)?.capabilities);
+ }
+
+ /**
+ * Save the address and, when a new one was typed, the key.
+ *
+ * A blank key field or the mask means "keep what is stored" — the field is
+ * never prefilled, so blank is the normal state of a form someone opened to
+ * change the URL.
+ *
+ * A private address is saved with a warning rather than refused: a
+ * self-hosted Dawarich on a LAN is the common case, not an attack. Only a URL
+ * that could not work at all (malformed, unresolvable, not http) is rejected.
+ */
+ async saveSettings(
+ userId: number,
+ url: string | undefined,
+ apiKey: string | undefined,
+ allowInsecureTls: boolean,
+ syncEnabled: boolean,
+ clientIp: string | null,
+ ): Promise<{ success: boolean; warning?: string; warningCode?: string; warningIp?: string; error?: string; code?: string }> {
+ const trimmedUrl = (url || '').trim();
+ let warning: string | undefined;
+ let warningCode: string | undefined;
+ let warningIp: string | undefined;
+
+ if (trimmedUrl) {
+ const ssrf = await checkSsrf(trimmedUrl);
+ if (!ssrf.allowed && !ssrf.isPrivate) {
+ // The sentence is kept for a caller reading the API directly; the code
+ // beside it is what the UI renders, in the reader's own language.
+ return { success: false, code: 'invalid_url', error: ssrf.error ?? 'Invalid Dawarich URL' };
+ }
+ if (ssrf.isPrivate) {
+ this.audit.writeAudit({
+ userId,
+ action: 'dawarich.private_ip_configured',
+ ip: clientIp,
+ details: { dawarich_url: trimmedUrl, resolved_ip: ssrf.resolvedIp },
+ });
+ warning = `Dawarich URL resolves to a private IP (${ssrf.resolvedIp}). Make sure this is intentional — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.`;
+ warningCode = 'private_ip';
+ warningIp = ssrf.resolvedIp ?? undefined;
+ }
+ }
+
+ const previousUrl = this.db.get<{ url: string | null }>(
+ 'SELECT url FROM dawarich_connections WHERE user_id = ?',
+ userId,
+ )?.url ?? null;
+
+ const provided = (apiKey || '').trim();
+ const newKey = provided && provided !== DAWARICH_KEY_MASK ? maybe_encrypt_api_key(provided) : undefined;
+
+ // All three writes or none: a row that took the new address but not the new
+ // key would point a credential minted for one instance at another one, and
+ // the next sync would send it there.
+ this.db.transaction(() => {
+ this.db.run(
+ `INSERT INTO dawarich_connections (user_id, url, allow_insecure_tls, sync_enabled, updated_at)
+ VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(user_id) DO UPDATE SET
+ url = excluded.url,
+ allow_insecure_tls = excluded.allow_insecure_tls,
+ sync_enabled = excluded.sync_enabled,
+ updated_at = CURRENT_TIMESTAMP`,
+ userId,
+ trimmedUrl || null,
+ allowInsecureTls ? 1 : 0,
+ syncEnabled ? 1 : 0,
+ );
+
+ if (newKey !== undefined) {
+ this.db.run('UPDATE dawarich_connections SET api_key = ? WHERE user_id = ?', newKey, userId);
+ }
+
+ // A key belongs to the instance it was minted on. Pointing the connection
+ // at a different host without retyping the key would quietly send that
+ // instance's credential somewhere it was never issued for, so the key goes
+ // with the host — and only with the host: correcting a typo in the path,
+ // or adding a trailing slash, keeps it.
+ if (newKey === undefined && movedHost(previousUrl, trimmedUrl)) {
+ this.db.run(
+ `UPDATE dawarich_connections
+ SET api_key = NULL, capabilities = NULL, last_sync_state = 'never',
+ last_sync_error = NULL, last_sync_at = NULL
+ WHERE user_id = ?`,
+ userId,
+ );
+ }
+
+ // Clearing the address leaves a key that can no longer be used for
+ // anything. Keeping it would be storing a live credential for a server
+ // nobody named.
+ if (!trimmedUrl) {
+ this.db.run(
+ `UPDATE dawarich_connections
+ SET api_key = NULL, capabilities = NULL, last_sync_state = 'never',
+ last_sync_error = NULL, last_sync_at = NULL
+ WHERE user_id = ?`,
+ userId,
+ );
+ }
+ });
+
+ return warning ? { success: true, warning, warningCode, warningIp } : { success: true };
+ }
+
+ /**
+ * Forget the connection entirely.
+ *
+ * The suggestions it produced are deliberately left alone: one that was
+ * accepted became a place or a journal entry the user wrote, and deleting
+ * their journal because they revoked an API key would be the integration
+ * destroying user content on its way out. What goes is the credential.
+ */
+ disconnect(userId: number, clientIp: string | null): void {
+ this.db.run('DELETE FROM dawarich_connections WHERE user_id = ?', userId);
+ this.audit.writeAudit({ userId, action: 'dawarich.disconnected', ip: clientIp, details: {} });
+ }
+
+ /**
+ * Try the connection as it is about to be saved — with the typed key if there
+ * is one, otherwise with the stored one.
+ *
+ * Answers 200 with `connected: false` rather than throwing, because "wrong
+ * key" is an answer the form has to render, not a server error.
+ */
+ async testConnection(
+ userId: number,
+ url: string | undefined,
+ apiKey: string | undefined,
+ allowInsecureTls: boolean,
+ ): Promise {
+ const typedKey = (apiKey || '').trim();
+ const stored = this.getCredentials(userId);
+ const baseUrl = (url || '').trim() || stored?.baseUrl || '';
+ const key = typedKey && typedKey !== DAWARICH_KEY_MASK ? typedKey : stored?.apiKey;
+
+ if (!baseUrl || !key) {
+ return { connected: false, error: 'not_connected' };
+ }
+
+ const creds: DawarichCreds = { baseUrl, apiKey: key, allowInsecureTls };
+ try {
+ const capabilities = await this.probeCapabilities(creds);
+ // Only persist against a connection that is actually stored — a test run
+ // from a half-filled form must not overwrite what is on file.
+ if (stored && baseUrl === stored.baseUrl) this.storeCapabilities(userId, capabilities);
+ return {
+ connected: true,
+ visitCount: capabilities.visits ? await this.countRecentVisits(creds) : 0,
+ capabilities,
+ };
+ } catch (err) {
+ if (err instanceof DawarichError) {
+ return { connected: false, error: err.code, ...(err.detail ? { errorDetail: err.detail } : {}) };
+ }
+ return { connected: false, error: 'unreachable' };
+ }
+ }
+
+ /**
+ * Ask the instance what it offers, once, instead of assuming a version.
+ *
+ * Each endpoint is tried with a tiny window; a 404 means this build does not
+ * have it and the feature that needs it stays off, which is how an older
+ * Dawarich still connects and still does most of what people want. Anything
+ * other than a 404 — an unreachable host, a rejected key — is a real failure
+ * and propagates: reporting "connected, but nothing works" would be worse
+ * than the error.
+ */
+ async probeCapabilities(creds: DawarichCreds): Promise {
+ const { version } = await this.client.probe(creds);
+ const to = new Date();
+ const from = new Date(to.getTime() - 24 * 60 * 60 * 1000);
+
+ const visitsProbe = await this.tryEndpoint(() => this.client.listVisits(creds, from, to));
+ const tracks = await this.tryEndpoint(() => this.client.listTracks(creds, from, to));
+ const points = await this.tryEndpoint(() => this.client.listPoints(creds, from, to));
+ const visitedCities = await this.tryEndpoint(() => this.client.listVisitedCities(creds, from, to));
+ // A coordinate no recording will ever be near: the probe is about whether
+ // the route answers, not about what it finds.
+ const locations = await this.tryEndpoint(() => this.client.findVisitsNear(creds, 0, 0, 50, 1));
+
+ const sample = visitsProbe.ok ? visitsProbe.value.visits[0] : undefined;
+ return {
+ visits: visitsProbe.ok,
+ tracks: tracks.ok,
+ points: points.ok,
+ locations: locations.ok,
+ visitedCities: visitedCities.ok,
+ // Read off a real row when there is one. With no visit in the probe window
+ // the honest answer is "not seen", and the sync re-probes on every run, so
+ // a later version's new field is picked up on its own.
+ visitUpdatedAt: !!sample && sample.updated_at != null,
+ visitCountryCode: !!sample?.place && sample.place.country_code != null,
+ serverVersion: version,
+ probedAt: new Date().toISOString(),
+ };
+ }
+
+ /** A 404 means "this build has no such endpoint"; everything else is a real failure. */
+ private async tryEndpoint(run: () => Promise): Promise<{ ok: true; value: T } | { ok: false }> {
+ try {
+ return { ok: true, value: await run() };
+ } catch (err) {
+ if (err instanceof DawarichError && err.code === 'not_found') return { ok: false };
+ throw err;
+ }
+ }
+
+ /** Visits in the last 30 days — the number the connection card shows as proof of life. */
+ private async countRecentVisits(creds: DawarichCreds): Promise {
+ const to = new Date();
+ const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
+ try {
+ const { visits } = await this.client.listVisits(creds, from, to);
+ return visits.length;
+ } catch {
+ // The connection is already proven at this point; a count is a nicety.
+ return 0;
+ }
+ }
+
+ storeCapabilities(userId: number, capabilities: DawarichCapabilities): void {
+ this.db.run(
+ 'UPDATE dawarich_connections SET capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?',
+ JSON.stringify(capabilities),
+ userId,
+ );
+ }
+
+ recordSyncResult(userId: number, state: DawarichSyncState, error: string | null): void {
+ this.db.run(
+ `UPDATE dawarich_connections
+ SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE user_id = ?`,
+ new Date().toISOString(),
+ state,
+ error,
+ userId,
+ );
+ }
+}
+
+interface ConnRow {
+ user_id: number;
+ url: string | null;
+ api_key: string | null;
+ allow_insecure_tls: number | null;
+ sync_enabled: number | null;
+ last_sync_at: string | null;
+ last_sync_state: string | null;
+ last_sync_error: string | null;
+ capabilities: string | null;
+}
+
+/**
+ * An unrecognised stored state reads as `never` rather than being passed
+ * through: the client switches on it, and a value it has no branch for would
+ * render as nothing at all.
+ */
+function normalizeSyncState(raw: string | null | undefined): DawarichSyncState {
+ return raw === 'ok' || raw === 'partial' || raw === 'failed' ? raw : 'never';
+}
+
+/**
+ * Capabilities are stored as JSON and read back defensively: the column is
+ * written by an older build's shape after an upgrade, and a malformed value
+ * means "not probed yet", never a crash on the settings page.
+ */
+function parseCapabilities(raw: string | null | undefined): DawarichCapabilities | null {
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw) as Partial;
+ if (typeof parsed !== 'object' || parsed === null) return null;
+ return {
+ visits: !!parsed.visits,
+ tracks: !!parsed.tracks,
+ points: !!parsed.points,
+ locations: !!parsed.locations,
+ visitedCities: !!parsed.visitedCities,
+ visitUpdatedAt: !!parsed.visitUpdatedAt,
+ visitCountryCode: !!parsed.visitCountryCode,
+ serverVersion: typeof parsed.serverVersion === 'string' ? parsed.serverVersion : null,
+ probedAt: typeof parsed.probedAt === 'string' ? parsed.probedAt : new Date(0).toISOString(),
+ };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Did the connection move to a different Dawarich instance?
+ *
+ * Scheme, host and port, because that is what decides where a credential is
+ * sent. A path change ("/", "/api", a typo) is the same instance. Anything
+ * unparseable is compared as written — a conservative answer that errs towards
+ * dropping the key rather than shipping it somewhere new.
+ */
+function movedHost(before: string | null, after: string): boolean {
+ if (!before || !after) return false;
+ try {
+ const a = new URL(before);
+ const b = new URL(after);
+ return a.origin !== b.origin;
+ } catch {
+ return before !== after;
+ }
+}
diff --git a/server/src/nest/public-api/api-token.guard.ts b/server/src/nest/public-api/api-token.guard.ts
index 3e2d7e642..e173b0ff1 100644
--- a/server/src/nest/public-api/api-token.guard.ts
+++ b/server/src/nest/public-api/api-token.guard.ts
@@ -21,13 +21,17 @@ import { TokenService } from '../tokens/token.service';
* - **No session JWT.** A session cookie leaking into a third-party integration is
* exactly what a machine credential exists to prevent, and a JWT that reaches
* this surface is almost always an accident.
- * - **No OAuth tokens (yet).** They carry scopes this surface does not interpret
- * yet, and accepting a credential whose restrictions you ignore is worse than
- * refusing it. When `/api/v1` grows write routes, scopes get honoured first.
+ * - **No OAuth tokens.** They carry MCP scopes, which are a different vocabulary
+ * from the read sections this surface understands — `trips:write` has no
+ * meaning on a read-only API, and quietly mapping one onto the other would be
+ * inventing permissions nobody granted. Accepting a credential whose
+ * restrictions you reinterpret is worse than refusing it.
*
- * The guard authenticates but does not authorise: it resolves `req.user` and stops
- * there. Which trips that user may read is decided per row against
- * `DatabaseService.canAccessTrip`, never from anything the caller sent.
+ * The guard resolves two things and then stops: **who** is calling (`req.user`)
+ * and **what their key may read** (`req.apiToken`). Both are needed and neither
+ * is sufficient. Which trips that user may reach is still decided per row
+ * against `DatabaseService.canAccessTrip`, never from anything the caller sent;
+ * the grant only narrows that further, and can never widen it.
*/
@Injectable()
export class ApiTokenGuard implements CanActivate {
@@ -42,14 +46,15 @@ export class ApiTokenGuard implements CanActivate {
401,
);
}
- const user = this.tokens.verifyApiToken(token);
- if (!user) {
+ const resolved = this.tokens.verifyApiTokenWithGrant(token);
+ if (!resolved) {
throw new HttpException(
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
401,
);
}
- req.user = user;
+ req.user = resolved.user;
+ req.apiToken = resolved.grant;
return true;
}
}
diff --git a/server/src/nest/public-api/public-api-request.ts b/server/src/nest/public-api/public-api-request.ts
index 0e9340888..dbed53680 100644
--- a/server/src/nest/public-api/public-api-request.ts
+++ b/server/src/nest/public-api/public-api-request.ts
@@ -1,5 +1,6 @@
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
+import { PUBLIC_API_SCOPES, type PublicApiScope } from '@trek/shared';
import type { RateLimitService } from '../common/rate-limit.service';
/**
@@ -44,3 +45,57 @@ export function requireUserId(req: Request): number {
}
return id;
}
+
+/**
+ * What the key on this request may read.
+ *
+ * A request that somehow reached a route without the guard has no grant, and
+ * the honest answer there is "nothing" rather than "everything" — the same
+ * fail-closed reasoning as `requireUserId` throwing a 401. A key with no
+ * narrowing has already been resolved to the full list by the token service,
+ * so there is no absent-means-all rule left to get wrong here.
+ */
+export function grantedScopes(req: Request): readonly PublicApiScope[] {
+ return req.apiToken?.scopes ?? [];
+}
+
+/**
+ * Refuse a request whose key does not cover the section it asked for.
+ *
+ * **403, and a code of its own.** Not 401: the credential is valid and
+ * re-authenticating will not help. Not 404: an integrator debugging a key they
+ * narrowed themselves needs to be told that, not sent looking for a missing
+ * trip. The two existing codes are deliberately left alone so
+ * "expired/invalid" and "not enough access" stay distinguishable.
+ */
+export function requireScope(req: Request, scope: PublicApiScope): void {
+ if (!grantedScopes(req).includes(scope)) {
+ throw new HttpException(
+ {
+ error: `This API key is not allowed to read ${scope}`,
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: scope,
+ },
+ 403,
+ );
+ }
+}
+
+/**
+ * Narrow a set of requested sections to what the key may actually read.
+ *
+ * Filtering rather than refusing, for one specific case: `include` defaults to
+ * everything, so a narrow key asking for a trip with no `include` at all would
+ * otherwise be refused for wanting sections it never named. It gets what it may
+ * have. A caller that *names* a forbidden section is a different matter and is
+ * refused by `requireScope` at the controller, because silently dropping what
+ * somebody explicitly asked for is how integrators end up debugging their own
+ * correct code.
+ */
+export function narrowToGrant(sections: readonly T[], req: Request): T[] {
+ const granted = new Set(grantedScopes(req));
+ return sections.filter((section) => granted.has(section));
+}
+
+/** Every section, for the callers that need the canonical list. */
+export const ALL_PUBLIC_API_SCOPES: readonly PublicApiScope[] = PUBLIC_API_SCOPES;
diff --git a/server/src/nest/public-api/public-api.controller.ts b/server/src/nest/public-api/public-api.controller.ts
index 5d1c0a61c..48775b9af 100644
--- a/server/src/nest/public-api/public-api.controller.ts
+++ b/server/src/nest/public-api/public-api.controller.ts
@@ -10,7 +10,12 @@ import {
} from '@trek/shared';
import { ApiTokenGuard } from './api-token.guard';
import { PublicApiService } from './public-api.service';
-import { enforcePublicApiRateLimit, requireUserId } from './public-api-request';
+import {
+ enforcePublicApiRateLimit,
+ narrowToGrant,
+ requireScope,
+ requireUserId,
+} from './public-api-request';
import { RateLimitService } from '../common/rate-limit.service';
/**
@@ -48,6 +53,7 @@ export class PublicApiController {
@Get('trips')
listTrips(@Req() req: Request): PublicApiTripList {
this.limit(req);
+ requireScope(req, 'trips');
return { trips: this.api.listTrips(requireUserId(req)) };
}
@@ -61,6 +67,7 @@ export class PublicApiController {
@Get('bucket-list')
listBucketList(@Req() req: Request): PublicApiBucketList {
this.limit(req);
+ requireScope(req, 'bucket-list');
return { items: this.api.listBucketList(requireUserId(req)) };
}
@@ -78,8 +85,20 @@ export class PublicApiController {
@Query('include') include?: string,
): PublicApiTrip {
this.limit(req);
+ requireScope(req, 'trips');
const tripId = parseTripId(id);
- const trip = this.api.getTrip(tripId, requireUserId(req), parseInclude(include));
+
+ // A named section the key may not read is refused outright; an unnamed one —
+ // `include` absent, which means "everything" — is simply narrowed. Silently
+ // dropping a section somebody asked for by name is how an integrator ends
+ // up debugging their own correct code.
+ const asked = parseInclude(include);
+ if (include !== undefined && include.trim() !== '') {
+ for (const section of asked) requireScope(req, section);
+ }
+ const allowed = narrowToGrant(asked, req);
+
+ const trip = this.api.getTrip(tripId, requireUserId(req), allowed);
if (!trip) {
throw new HttpException({ error: 'Trip not found' }, 404);
}
diff --git a/server/src/nest/tokens/token.service.ts b/server/src/nest/tokens/token.service.ts
index a5df4ca80..294494c2c 100644
--- a/server/src/nest/tokens/token.service.ts
+++ b/server/src/nest/tokens/token.service.ts
@@ -1,5 +1,10 @@
import { Injectable } from '@nestjs/common';
import { randomBytes, createHash } from 'crypto';
+import {
+ PUBLIC_API_SCOPES,
+ type PublicApiGrant,
+ type PublicApiScope,
+} from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { EphemeralTokenService } from '../auth/ephemeral-token.service';
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
@@ -57,21 +62,46 @@ export class TokenService {
}
private listTokens(userId: number, kind: TokenKind) {
- return this.db.all(
- 'SELECT id, name, token_prefix, created_at, last_used_at FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
+ const rows = this.db.all(
+ 'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
);
+ // The MCP list keeps the exact shape it has always had: those tokens carry
+ // no read scopes, and two columns that are always "everything" would be
+ // noise in a panel that cannot act on them.
+ if (kind === 'mcp') {
+ return rows.map(({ scope_mode: _mode, api_scopes: _scopes, ...rest }) => rest);
+ }
+ return rows.map((row) => {
+ const grant = resolveGrant(row.scope_mode, row.api_scopes);
+ return {
+ id: row.id,
+ name: row.name,
+ token_prefix: row.token_prefix,
+ created_at: row.created_at,
+ last_used_at: row.last_used_at,
+ scope_mode: grant.mode,
+ scopes: grant.scopes,
+ };
+ });
}
createMcpToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'mcp');
}
- createApiToken(userId: number, rawName: unknown) {
- return this.createToken(userId, rawName, 'api');
+ /**
+ * An integration key, optionally narrowed to a few sections.
+ *
+ * Omitting `scopes` means the key reads everything — what every key did
+ * before this argument existed. Narrowing is opt-in on purpose: shipping the
+ * column must not change what an already-running integration can do.
+ */
+ createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) {
+ return this.createToken(userId, rawName, 'api', scopes);
}
- private createToken(userId: number, rawName: unknown, kind: TokenKind): { error?: string; status?: number; token?: Record } {
+ private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { error?: string; status?: number; token?: Record } {
const name = rawName as string | undefined;
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
@@ -83,9 +113,15 @@ export class TokenService {
const tokenHash = createHash('sha256').update(rawToken).digest('hex');
const tokenPrefix = rawToken.slice(0, 13);
+ // An MCP token never carries read scopes, so it is stored as "everything" —
+ // the column default — rather than being handed a list it would ignore.
+ const narrowed = kind === 'api' ? sanitizeScopes(scopes) : null;
+
const result = this.db.run(
- 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind) VALUES (?, ?, ?, ?, ?)',
- userId, name.trim(), tokenHash, tokenPrefix, kind
+ 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ userId, name.trim(), tokenHash, tokenPrefix, kind,
+ narrowed ? 'limited' : 'all',
+ narrowed ? JSON.stringify(narrowed) : null,
);
const token = this.db.get(
@@ -93,7 +129,10 @@ export class TokenService {
result.lastInsertRowid
);
- return { token: { ...(token as object), raw_token: rawToken } };
+ const grant = kind === 'api'
+ ? { scope_mode: narrowed ? 'limited' : 'all', scopes: narrowed ?? [...PUBLIC_API_SCOPES] }
+ : {};
+ return { token: { ...(token as object), ...grant, raw_token: rawToken } };
}
deleteMcpToken(userId: number, tokenId: string) {
@@ -183,6 +222,27 @@ export class TokenService {
return this.verifyToken(rawToken, 'api');
}
+ /**
+ * The same lookup, plus what the key is allowed to read.
+ *
+ * A second method rather than a widened `verifyApiToken`: the guard needs
+ * both halves, nothing else needs either, and the existing signature is
+ * pinned by tests that assert exactly a `User`.
+ */
+ verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null {
+ const hash = createHash('sha256').update(rawToken).digest('hex');
+ const row = this.db.get(`
+ SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
+ FROM mcp_tokens mt
+ JOIN users u ON mt.user_id = u.id
+ WHERE mt.token_hash = ? AND mt.kind = 'api'
+ `, hash);
+ if (!row) return null;
+ this.db.run('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?', hash);
+ const { scope_mode, api_scopes, ...user } = row;
+ return { user: user as User, grant: resolveGrant(scope_mode, api_scopes) };
+ }
+
/**
* Hash, look up, and require the kind the calling surface accepts.
*
@@ -206,3 +266,58 @@ export class TokenService {
return null;
}
}
+
+interface TokenRow {
+ id: number;
+ name: string;
+ token_prefix: string;
+ created_at: string;
+ last_used_at: string | null;
+ scope_mode: string | null;
+ api_scopes: string | null;
+}
+
+/**
+ * The requested sections, cleaned up — unknown values dropped, duplicates
+ * collapsed, order pinned to the canonical list so two keys with the same
+ * access read the same in the panel.
+ *
+ * Returns null for "no narrowing was asked for", which is what makes the caller
+ * store full access rather than an empty list that would lock the key out of
+ * everything.
+ */
+function sanitizeScopes(scopes: readonly string[] | undefined): PublicApiScope[] | null {
+ if (!scopes || scopes.length === 0) return null;
+ const wanted = new Set(scopes);
+ const kept = PUBLIC_API_SCOPES.filter((scope) => wanted.has(scope));
+ return kept.length > 0 ? [...kept] : null;
+}
+
+/**
+ * What a stored row means, read defensively — in both directions.
+ *
+ * A row that does not say 'limited' is a full-access key: that is what every key
+ * written before this column existed had, and a missing narrowing must not cost
+ * a running integration its access. Narrowing is a deliberate act; its absence
+ * is not a denial.
+ *
+ * But once a row HAS said 'limited', every way of failing to read its list
+ * denies. Unparseable JSON, a non-array, or a list of scope names this version
+ * no longer knows (a renamed constant, a hand-edited row) resolves to no scopes
+ * at all rather than to everything: the whole reason `scope_mode` is an explicit
+ * flag instead of "NULL means everything" is that a key minted as restricted
+ * must never widen on its own. A key that stops working is a support ticket; a
+ * key that quietly reads every trip is the bug this feature exists to prevent.
+ */
+function resolveGrant(mode: string | null, raw: string | null): PublicApiGrant {
+ if (mode !== 'limited') return { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
+ let parsed: unknown;
+ try {
+ parsed = raw ? JSON.parse(raw) : null;
+ } catch {
+ parsed = null;
+ }
+ if (!Array.isArray(parsed)) return { mode: 'limited', scopes: [] };
+ const kept = sanitizeScopes(parsed.filter((value): value is string => typeof value === 'string'));
+ return { mode: 'limited', scopes: kept ?? [] };
+}
diff --git a/server/src/typings/express.d.ts b/server/src/typings/express.d.ts
index 5d51ff72f..9faf52609 100644
--- a/server/src/typings/express.d.ts
+++ b/server/src/typings/express.d.ts
@@ -3,6 +3,7 @@
// (`(req as AuthRequest).user`, `getRequest()`) with
// a single source of truth so downstream code can read `req.user` directly.
+import type { PublicApiGrant } from '@trek/shared';
import type { User } from '../types';
declare module 'express-serve-static-core' {
@@ -13,6 +14,16 @@ declare module 'express-serve-static-core' {
* `optionalAuthenticate` to signal "checked, but unauthenticated".
*/
user?: User | null;
+ /**
+ * What the `/api/v1` integration key presented on this request may read.
+ * Set by `ApiTokenGuard`, and only there.
+ *
+ * Its own field rather than something folded into `user`: the grant belongs
+ * to the credential, not to the person, and a cookie session has no grant at
+ * all. Putting it on `user` would leak a token's restrictions into every
+ * other auth path that happens to read the same object.
+ */
+ apiToken?: PublicApiGrant;
}
}
diff --git a/server/tests/e2e/dawarich.e2e.test.ts b/server/tests/e2e/dawarich.e2e.test.ts
new file mode 100644
index 000000000..1d201cdf3
--- /dev/null
+++ b/server/tests/e2e/dawarich.e2e.test.ts
@@ -0,0 +1,345 @@
+/**
+ * Dawarich e2e — drives /api/integrations/dawarich through the REAL AddonGuard
+ * and the REAL JwtAuthGuard, over the real DI-native services (DatabaseModule +
+ * DawarichModule) against a temp SQLite db carrying the full schema.
+ *
+ * Two things are replaced and nothing else:
+ *
+ * - `AddonsService.isAddonEnabled`, because the gate is what is under test and
+ * an admin toggle is not worth seeding for it;
+ * - `DawarichClient`, because a test must never open a socket to somebody's
+ * location archive. Every method is a spy that fails loudly if a route this
+ * file exercises turns out to reach upstream after all.
+ *
+ * Covered: the 404-before-401 ordering of the addon gate, the unauthenticated
+ * 401, the shape of a connection nobody has configured, the two URL rejections
+ * that need no DNS, the suggestions envelope's `connected: false`, and the
+ * cross-user isolation — someone else's suggestion is invisible and untouchable
+ * through your own session, and answers the same 404 a missing one does.
+ */
+import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
+import request from 'supertest';
+import cookieParser from 'cookie-parser';
+import type { Server } from 'http';
+import { Test } from '@nestjs/testing';
+import { sessionCookie } from './harness';
+
+const { db } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const Database = require('better-sqlite3');
+ const tmp = new Database(':memory:');
+ tmp.exec('PRAGMA journal_mode = WAL');
+ tmp.exec('PRAGMA foreign_keys = ON');
+ return { db: tmp };
+});
+
+vi.mock('../../src/db/database', () => ({
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: (tripId: number | string, userId: number) =>
+ db
+ .prepare(
+ 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)',
+ )
+ .get(userId, tripId, userId),
+ isOwner: () => false,
+}));
+
+const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
+vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn(), broadcast: vi.fn() }));
+
+import { createTables } from '../../src/db/schema';
+import { runMigrations } from '../../src/db/migrations';
+import { createUser } from '../helpers/factories';
+import { DawarichModule } from '../../src/nest/integrations/dawarich.module';
+import { DawarichClient } from '../../src/nest/integrations/dawarich.client';
+import { DatabaseModule } from '../../src/nest/database/database.module';
+import { AddonsService } from '../../src/nest/addons/addons.service';
+import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
+import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
+
+/**
+ * Every outbound call an instance would ever receive, stubbed to throw. A route
+ * that unexpectedly reaches upstream fails this suite instead of quietly
+ * hanging on a DNS lookup in CI.
+ */
+function makeClientStub() {
+ const boom = (name: string) => vi.fn(() => {
+ throw new Error(`DawarichClient.${name} must not be called from the e2e suite`);
+ });
+ return {
+ probe: boom('probe'),
+ listVisits: boom('listVisits'),
+ listTracks: boom('listTracks'),
+ listPoints: boom('listPoints'),
+ listVisitedCities: boom('listVisitedCities'),
+ findVisitsNear: boom('findVisitsNear'),
+ };
+}
+
+/** A suggestion row with only the NOT NULL columns filled in. */
+function seedSuggestion(userId: number, sourceVisitId: string, name: string): number {
+ const info = db
+ .prepare(
+ `INSERT INTO dawarich_visit_suggestions
+ (user_id, source_visit_id, name, lat, lng, started_at, ended_at, duration_minutes, local_date, source_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(userId, sourceVisitId, name, 48.8584, 2.2945, '2026-05-01T10:00:00+02:00', '2026-05-01T12:30:00+02:00', 150, '2026-05-01', 'hash-' + sourceVisitId);
+ return Number(info.lastInsertRowid);
+}
+
+describe('Dawarich e2e (real addon gate + real auth guard + real services + temp SQLite)', () => {
+ let server: Server;
+ let app: Awaited>;
+ let ownerId: number;
+ let strangerId: number;
+ let strangerSuggestionId: number;
+
+ async function build() {
+ const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, DawarichModule] })
+ .overrideProvider(AddonsService)
+ .useValue({ isAddonEnabled })
+ .overrideProvider(DawarichClient)
+ .useValue(makeClientStub())
+ .compile();
+ const nest = moduleRef.createNestApplication();
+ nest.use(cookieParser());
+ nest.useGlobalFilters(new TrekExceptionFilter());
+ // Mirror the production APP_PIPE (app.module.ts): DTO-typed bodies validate
+ // by metatype, exactly as they do under buildApp().
+ nest.useGlobalPipes(new ZodValidationPipe());
+ await nest.init();
+ return nest;
+ }
+
+ beforeAll(async () => {
+ createTables(db as never);
+ runMigrations(db as never);
+ ownerId = createUser(db as never, { username: 'dawarich-owner', email: 'dawarich-owner@test.example' }).user.id;
+ strangerId = createUser(db as never, { username: 'dawarich-stranger', email: 'dawarich-stranger@test.example' }).user.id;
+ strangerSuggestionId = seedSuggestion(strangerId, 'visit-stranger-1', 'Eiffel Tower');
+ app = await build();
+ server = app.getHttpServer();
+ });
+
+ beforeEach(() => {
+ isAddonEnabled.mockReturnValue(true);
+ db.prepare('DELETE FROM dawarich_connections').run();
+ db.prepare('DELETE FROM dawarich_visit_suggestions WHERE user_id = ?').run(ownerId);
+ });
+
+ afterAll(async () => {
+ await app.close();
+ });
+
+ // ── Auth + addon gate ────────────────────────────────────────────────────
+
+ it('DAWARICH-E2E-001: 401 without a session cookie', async () => {
+ const res = await request(server).get('/api/integrations/dawarich/settings');
+ expect(res.status).toBe(401);
+ });
+
+ it('DAWARICH-E2E-002: a session on a disabled addon gets 404 with the Dawarich label, not a 403 that confirms the route', async () => {
+ isAddonEnabled.mockReturnValue(false);
+ const res = await request(server)
+ .get('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' });
+ });
+
+ it('DAWARICH-E2E-003: the addon gate beats the auth guard — no cookie and no addon is still 404, so an anonymous probe learns nothing', async () => {
+ isAddonEnabled.mockReturnValue(false);
+ const res = await request(server).get('/api/integrations/dawarich/suggestions');
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Dawarich addon is not enabled' });
+ });
+
+ it('DAWARICH-E2E-004: a disabled addon 404s the write routes too, and nothing is persisted', async () => {
+ isAddonEnabled.mockReturnValue(false);
+ const res = await request(server)
+ .put('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ url: 'https://dawarich.example', apiKey: 'secret' });
+ expect(res.status).toBe(404);
+ expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
+ });
+
+ it('DAWARICH-E2E-005: the addon flag is re-read per request — switching it back on reopens the route without a restart', async () => {
+ isAddonEnabled.mockReturnValue(false);
+ expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(404);
+ isAddonEnabled.mockReturnValue(true);
+ expect((await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId))).status).toBe(200);
+ });
+
+ // ── Connection ───────────────────────────────────────────────────────────
+
+ it('DAWARICH-E2E-010: GET settings for a user who has never connected answers the empty connection, with the poll defaulted on', async () => {
+ const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ url: '',
+ // Never the key, and not even a placeholder when there is nothing stored.
+ apiKeyMasked: '',
+ allowInsecureTls: false,
+ // No row yet is a user who has not opened the card; the default there is
+ // "poll once it is connected", not "off".
+ syncEnabled: true,
+ connected: false,
+ lastSyncAt: null,
+ lastSyncState: 'never',
+ lastSyncError: null,
+ capabilities: null,
+ });
+ });
+
+ it('DAWARICH-E2E-011: PUT settings with an unparseable URL is a 400 and writes nothing', async () => {
+ const res = await request(server)
+ .put('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ url: 'not a url', apiKey: 'secret' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'Invalid URL' });
+ // Crucially: the key is not stored on a rejected address either.
+ expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
+ });
+
+ it('DAWARICH-E2E-012: PUT settings with a non-HTTP scheme is a 400 — file:// and friends never reach the fetch layer', async () => {
+ const res = await request(server)
+ .put('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ url: 'file:///etc/passwd' });
+
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'Only HTTP and HTTPS URLs are allowed' });
+ expect(db.prepare('SELECT COUNT(*) AS n FROM dawarich_connections').get()).toEqual({ n: 0 });
+ });
+
+ it('DAWARICH-E2E-013: PUT settings rejects a body the shared contract does not accept, before any of this runs', async () => {
+ const res = await request(server)
+ .put('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ apiKey: 'secret' });
+
+ expect(res.status).toBe(400);
+ expect(res.body.error).toMatch(/^url: /);
+ });
+
+ it('DAWARICH-E2E-014: clearing the address saves the row, keeps the connection unconnected and persists the poll toggle', async () => {
+ const put = await request(server)
+ .put('/api/integrations/dawarich/settings')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ url: '', syncEnabled: false });
+ expect(put.status).toBe(200);
+ expect(put.body).toEqual({ success: true });
+
+ const row = db.prepare('SELECT url, api_key, sync_enabled FROM dawarich_connections WHERE user_id = ?').get(ownerId);
+ expect(row).toEqual({ url: null, api_key: null, sync_enabled: 0 });
+
+ const res = await request(server).get('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
+ expect(res.body.connected).toBe(false);
+ expect(res.body.syncEnabled).toBe(false);
+ expect(res.body.apiKeyMasked).toBe('');
+ });
+
+ it('DAWARICH-E2E-015: DELETE settings answers 200 even when there was nothing to disconnect', async () => {
+ const res = await request(server).delete('/api/integrations/dawarich/settings').set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ success: true });
+ });
+
+ // ── Suggestions ──────────────────────────────────────────────────────────
+
+ it('DAWARICH-E2E-020: GET suggestions without a connection answers the envelope with connected:false — an empty list alone would read as "no stays"', async () => {
+ const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({
+ suggestions: [],
+ connected: false,
+ lastSyncAt: null,
+ lastSyncState: 'never',
+ lastSyncError: null,
+ });
+ });
+
+ it('DAWARICH-E2E-021: an unknown state filter is a 400 rather than a silently unfiltered list', async () => {
+ const res = await request(server)
+ .get('/api/integrations/dawarich/suggestions?state=pending')
+ .set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'state must be one of: new, accepted, dismissed' });
+ });
+
+ it('DAWARICH-E2E-022: a tripId that only starts with digits is a 400', async () => {
+ const res = await request(server)
+ .get('/api/integrations/dawarich/suggestions?tripId=12abc')
+ .set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(400);
+ expect(res.body).toEqual({ error: 'Invalid tripId' });
+ });
+
+ // ── Cross-user isolation ─────────────────────────────────────────────────
+
+ it("DAWARICH-E2E-030: another user's suggestion never appears in your own list", async () => {
+ const mine = seedSuggestion(ownerId, 'visit-mine-1', 'Gare du Nord');
+
+ const res = await request(server).get('/api/integrations/dawarich/suggestions').set('Cookie', sessionCookie(ownerId));
+ expect(res.status).toBe(200);
+ const ids = res.body.suggestions.map((s: { id: number }) => s.id);
+ expect(ids).toEqual([mine]);
+ expect(ids).not.toContain(strangerSuggestionId);
+ // And the row really is there for the other user, so this is isolation and
+ // not an empty table.
+ expect(db.prepare('SELECT user_id FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
+ .toEqual({ user_id: strangerId });
+ });
+
+ it("DAWARICH-E2E-031: PUT state on another user's suggestion is a 404 — the same answer a missing id gets, so nothing is enumerable", async () => {
+ const res = await request(server)
+ .put(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/state`)
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ state: 'dismissed' });
+
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Suggestion not found' });
+ expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
+ .toEqual({ state: 'new' });
+
+ const missing = await request(server)
+ .put('/api/integrations/dawarich/suggestions/999999/state')
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ state: 'dismissed' });
+ expect(missing.status).toBe(404);
+ expect(missing.body).toEqual(res.body);
+ });
+
+ it("DAWARICH-E2E-032: POST accept on another user's suggestion is a 404 with the domain's own code", async () => {
+ const res = await request(server)
+ .post(`/api/integrations/dawarich/suggestions/${strangerSuggestionId}/accept`)
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ target: 'place', tripId: 1 });
+
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Suggestion not found', code: 'not_found' });
+ expect(db.prepare('SELECT state, target FROM dawarich_visit_suggestions WHERE id = ?').get(strangerSuggestionId))
+ .toEqual({ state: 'new', target: null });
+ });
+
+ it('DAWARICH-E2E-033: your own suggestion is reachable through the same route, which is what makes the two 404s above meaningful', async () => {
+ const mine = seedSuggestion(ownerId, 'visit-mine-2', 'Musée d’Orsay');
+
+ const res = await request(server)
+ .put(`/api/integrations/dawarich/suggestions/${mine}/state`)
+ .set('Cookie', sessionCookie(ownerId))
+ .send({ state: 'dismissed' });
+
+ expect(res.status).toBe(200);
+ expect(res.body.id).toBe(mine);
+ expect(res.body.state).toBe('dismissed');
+ expect(db.prepare('SELECT state FROM dawarich_visit_suggestions WHERE id = ?').get(mine)).toEqual({ state: 'dismissed' });
+ });
+});
diff --git a/server/tests/e2e/public-api.e2e.test.ts b/server/tests/e2e/public-api.e2e.test.ts
index 2c45b8076..b7321bd2e 100644
--- a/server/tests/e2e/public-api.e2e.test.ts
+++ b/server/tests/e2e/public-api.e2e.test.ts
@@ -21,10 +21,15 @@ const { db } = vi.hoisted(() => {
tmp.exec(`
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);
+ -- scope_mode/api_scopes are spelled exactly as the scopes migration adds
+ -- them (NOT NULL DEFAULT 'all', and a nullable JSON list): a row here has to
+ -- be able to be the row the guard reads in production, or the scope cases
+ -- below would be passing against a table that does not exist anywhere else.
CREATE TABLE mcp_tokens (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
name TEXT NOT NULL, token_hash TEXT NOT NULL, token_prefix TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_used_at DATETIME,
- kind TEXT NOT NULL DEFAULT 'mcp');
+ kind TEXT NOT NULL DEFAULT 'mcp',
+ scope_mode TEXT NOT NULL DEFAULT 'all', api_scopes TEXT);
CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
title TEXT NOT NULL, description TEXT, start_date TEXT, end_date TEXT, currency TEXT,
is_archived INTEGER DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -76,6 +81,10 @@ vi.mock('../../src/db/database', async (importActual) => {
import { DatabaseModule } from '../../src/nest/database/database.module';
import { RateLimitModule } from '../../src/nest/common/rate-limit.module';
import { PublicApiModule } from '../../src/nest/public-api/public-api.module';
+import { ApiTokenGuard } from '../../src/nest/public-api/api-token.guard';
+import { TokensModule } from '../../src/nest/tokens/tokens.module';
+import { PublicStatsController } from '../../src/nest/atlas/public-stats.controller';
+import { AtlasService } from '../../src/nest/atlas/atlas.service';
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
/** Mints a token the way TokenService does, so the guard's hash lookup is real. */
@@ -90,17 +99,67 @@ function seedToken(userId: number, raw: string, kind: 'api' | 'mcp' = 'api'): st
return raw;
}
+/**
+ * The same mint, narrowed. `rawScopes` goes into the column verbatim — including
+ * the deliberately unparseable value one case needs, which is why this takes a
+ * string rather than an array.
+ */
+function seedLimitedToken(userId: number, raw: string, rawScopes: string): string {
+ db.prepare(
+ 'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix, kind, scope_mode, api_scopes) VALUES (?, ?, ?, ?, ?, ?, ?)',
+ ).run(
+ userId,
+ 'test-limited',
+ createHash('sha256').update(raw).digest('hex'),
+ raw.slice(0, 13),
+ 'api',
+ 'limited',
+ rawScopes,
+ );
+ return raw;
+}
+
const ADA_TOKEN = 'trek_' + 'a'.repeat(48);
const BOB_TOKEN = 'trek_' + 'b'.repeat(48);
/** A valid credential for /mcp — must not open this surface. */
const MCP_TOKEN = 'trek_' + 'c'.repeat(48);
+/** Narrowed keys, all Ada's, so only the grant differs between them. */
+const TRIPS_ONLY_TOKEN = 'trek_' + 'd'.repeat(48);
+const TRIPS_DAYS_TOKEN = 'trek_' + 'e'.repeat(48);
+/** api_scopes that cannot be parsed — the fall-back-to-everything case. */
+const BROKEN_SCOPES_TOKEN = 'trek_' + 'f'.repeat(48);
+
+/** Stubbed so /api/v1/stats can be mounted without AtlasModule's whole graph. */
+const STATS = {
+ getTravelStats: () => ({
+ countries: ['JP', 'IT'],
+ cities: ['tokyo'],
+ coords: [],
+ totalTrips: 2,
+ totalDays: 3,
+ totalPlaces: 4,
+ totalDistanceKm: 1234,
+ }),
+ lastTrip: () => null,
+};
describe('Public API v1 e2e (real guard + real SQL)', () => {
let server: Server;
let app: Awaited>;
async function build() {
- const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, RateLimitModule, PublicApiModule] }).compile();
+ const moduleRef = await Test.createTestingModule({
+ imports: [DatabaseModule, RateLimitModule, TokensModule, PublicApiModule],
+ // `/api/v1/stats` lives in atlas/ because its figures do, but it is guarded
+ // and scoped by this directory's code — so it is mounted here with the real
+ // guard and a stubbed AtlasService. Importing AtlasModule instead would pull
+ // AuthModule and the storage registry, which reads app_settings on init and
+ // would need half the schema this file deliberately does not carry. What is
+ // under test is the refusal, not the arithmetic (public-stats.controller.test
+ // owns that), and the refusal happens before AtlasService is ever touched.
+ controllers: [PublicStatsController],
+ providers: [ApiTokenGuard, { provide: AtlasService, useValue: STATS }],
+ }).compile();
const nest = moduleRef.createNestApplication();
nest.useGlobalFilters(new TrekExceptionFilter());
await nest.init();
@@ -113,6 +172,13 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
seedToken(1, ADA_TOKEN);
seedToken(2, BOB_TOKEN);
seedToken(1, MCP_TOKEN, 'mcp');
+ // ADA_TOKEN above is seeded without ever naming the two scope columns — the
+ // row a key minted before the feature existed leaves behind. It is the
+ // backwards-compatibility case and every existing test in this file rides on
+ // it, so if narrowing ever leaks into the default those tests fail first.
+ seedLimitedToken(1, TRIPS_ONLY_TOKEN, JSON.stringify(['trips']));
+ seedLimitedToken(1, TRIPS_DAYS_TOKEN, JSON.stringify(['trips', 'days', 'notes']));
+ seedLimitedToken(1, BROKEN_SCOPES_TOKEN, '{"not":"a list"');
// Ada owns trip 1; Bob owns trip 2; trip 3 is Bob's but Ada is a member.
db.prepare(
@@ -160,6 +226,20 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
return token ? req.set('Authorization', `Bearer ${token}`) : req;
};
+ /**
+ * Several reads in a row, one after the other.
+ *
+ * Not `Promise.all`: the Nest app is never told to listen, so supertest binds
+ * an ephemeral port itself on the first request. Fired in parallel, four
+ * requests race four `listen(0)` calls on the same server and the losers come
+ * back as ECONNRESET — reliably on a loaded CI runner, almost never here.
+ */
+ const getEach = async (...calls: Array<[string, string?]>) => {
+ const out = [];
+ for (const [path, token] of calls) out.push(await get(path, token));
+ return out;
+ };
+
describe('authentication', () => {
it('401s without a token', async () => {
const res = await get('/api/v1/trips');
@@ -359,4 +439,165 @@ describe('Public API v1 e2e (real guard + real SQL)', () => {
expect(res.status).toBe(401);
});
});
+ /**
+ * Read scopes (#2279).
+ *
+ * The question a mock cannot answer: does a key stored in the database a
+ * particular way actually reach the sections it should, and stop at the ones it
+ * should not? Every case below starts from a real row and ends at a real HTTP
+ * status, because the two halves — what the column says and what the route does
+ * — are written in different files and have no reason to agree on their own.
+ *
+ * The first case is the important one. Narrowing is a feature; not breaking the
+ * keys that already exist is a promise.
+ */
+ describe('read scopes', () => {
+ it('PUBAPI-SCOPE-001: a key minted before scopes existed is stored as a full grant', () => {
+ const row = db
+ .prepare('SELECT scope_mode, api_scopes FROM mcp_tokens WHERE token_prefix = ?')
+ .get(ADA_TOKEN.slice(0, 13)) as { scope_mode: string; api_scopes: string | null };
+ // The two columns were never named at insert time — exactly the row the
+ // ALTER leaves behind for a key that already existed.
+ expect(row).toEqual({ scope_mode: 'all', api_scopes: null });
+ });
+
+ it('PUBAPI-SCOPE-002: and still reads every section, which is the whole promise', async () => {
+ const [trips, bucket, stats, trip] = await getEach(
+ ['/api/v1/trips', ADA_TOKEN],
+ ['/api/v1/bucket-list', ADA_TOKEN],
+ ['/api/v1/stats', ADA_TOKEN],
+ ['/api/v1/trips/1', ADA_TOKEN],
+ );
+ expect([trips.status, bucket.status, stats.status, trip.status]).toEqual([200, 200, 200, 200]);
+ expect(stats.body).toMatchObject({ total_trips: 2, total_countries: 2 });
+ // The full payload, not a narrowed one: every section is there.
+ expect(trip.body.days).toHaveLength(2);
+ expect(trip.body.accommodations).toBeDefined();
+ expect(trip.body.travellers).toBeDefined();
+ expect(trip.body.unplanned_places).toBeDefined();
+ expect(trip.body.unscheduled_reservations).toBeDefined();
+ });
+
+ it('PUBAPI-SCOPE-003: a key narrowed to trips reads trips', async () => {
+ const res = await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
+ expect(res.status).toBe(200);
+ expect(res.body.trips.map((t: { id: number }) => t.id).sort()).toEqual([1, 3]);
+ });
+
+ it('PUBAPI-SCOPE-004: and is refused the bucket list, with a code of its own', async () => {
+ const res = await get('/api/v1/bucket-list', TRIPS_ONLY_TOKEN);
+ expect(res.status).toBe(403);
+ expect(res.body).toEqual({
+ error: 'This API key is not allowed to read bucket-list',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'bucket-list',
+ });
+ // And nothing of the withheld list leaked out through the refusal.
+ expect(JSON.stringify(res.body)).not.toContain('Hokkaido');
+ });
+
+ it('PUBAPI-SCOPE-005: and refused the stats route, the widest answer on the surface', async () => {
+ const res = await get('/api/v1/stats', TRIPS_ONLY_TOKEN);
+ expect(res.status).toBe(403);
+ expect(res.body).toEqual({
+ error: 'This API key is not allowed to read stats',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'stats',
+ });
+ });
+
+ /**
+ * A valid credential that may not read something is not an authentication
+ * problem, and the two have to stay distinguishable: re-minting a key fixes a
+ * 401 and does nothing at all for a 403.
+ */
+ it('PUBAPI-SCOPE-006: the refusal is a 403 and leaves both 401 bodies exactly as they were', async () => {
+ const [none, unknown, wrongKind, noneOnStats] = await getEach(
+ ['/api/v1/trips'],
+ ['/api/v1/trips', 'trek_' + 'z'.repeat(48)],
+ ['/api/v1/trips', MCP_TOKEN],
+ ['/api/v1/stats'],
+ );
+ expect(none.status).toBe(401);
+ expect(none.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
+ expect(unknown.status).toBe(401);
+ expect(unknown.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
+ expect(wrongKind.status).toBe(401);
+ expect(wrongKind.body).toEqual({ error: 'Invalid API token', code: 'API_TOKEN_INVALID' });
+ expect(noneOnStats.status).toBe(401);
+ expect(noneOnStats.body).toEqual({ error: 'API token required', code: 'API_TOKEN_REQUIRED' });
+ });
+
+ it('PUBAPI-SCOPE-007: a trip asked for without include carries only the granted sections', async () => {
+ const res = await get('/api/v1/trips/1', TRIPS_ONLY_TOKEN);
+ // Not a refusal: `include` absent means "everything", and a key that never
+ // named a forbidden section gets what it may have.
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({ id: 1, title: 'Toskana' });
+ expect(res.body.days).toBeUndefined();
+ expect(res.body.accommodations).toBeUndefined();
+ expect(res.body.travellers).toBeUndefined();
+ expect(res.body.unplanned_places).toBeUndefined();
+ expect(res.body.unscheduled_reservations).toBeUndefined();
+ expect(JSON.stringify(res.body)).not.toContain('Uffizien');
+ });
+
+ it('PUBAPI-SCOPE-008: a wider grant fills in exactly the sections it covers', async () => {
+ const res = await get('/api/v1/trips/1', TRIPS_DAYS_TOKEN);
+ expect(res.status).toBe(200);
+ expect(res.body.days).toHaveLength(2);
+ expect(res.body.days[0].day_notes).toEqual([{ text: 'Tickets mitnehmen', time: '09:00' }]);
+ // notes was granted, places was not — and days is not a back door to them.
+ expect(res.body.days[0].places).toEqual([]);
+ expect(res.body.accommodations).toBeUndefined();
+ expect(JSON.stringify(res.body)).not.toContain('Uffizien');
+ });
+
+ it('PUBAPI-SCOPE-009: an include the key does cover still answers', async () => {
+ const res = await get('/api/v1/trips/1?include=days,notes', TRIPS_DAYS_TOKEN);
+ expect(res.status).toBe(200);
+ expect(res.body.days[0].day_notes).toHaveLength(1);
+ });
+
+ it('PUBAPI-SCOPE-010: naming a forbidden section is refused rather than silently dropped', async () => {
+ const res = await get('/api/v1/trips/1?include=days,places', TRIPS_DAYS_TOKEN);
+ expect(res.status).toBe(403);
+ expect(res.body).toEqual({
+ error: 'This API key is not allowed to read places',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'places',
+ });
+ });
+
+ it('PUBAPI-SCOPE-011: a key that says it is narrowed and cannot say how reads nothing', async () => {
+ // The direction matters. A key that never said 'limited' keeps the full
+ // access it was minted with; one that HAS said it is restricted must not
+ // widen because its list became unreadable — a hand-edited row, or a scope
+ // constant a later version renamed away. Failing closed turns that into a
+ // support ticket instead of a key quietly reading every trip.
+ const [bucket, trip] = await getEach(
+ ['/api/v1/bucket-list', BROKEN_SCOPES_TOKEN],
+ ['/api/v1/trips/1', BROKEN_SCOPES_TOKEN],
+ );
+ expect(bucket.status).toBe(403);
+ expect(bucket.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN' });
+ expect(trip.status).toBe(403);
+ });
+
+ it('PUBAPI-SCOPE-012: a scope never widens access — the owner check still decides', async () => {
+ // 'trips' granted, Bob's trip asked for: a grant narrows what a key may read
+ // and can never hand it a row its owner cannot reach.
+ const res = await get('/api/v1/trips/2', TRIPS_ONLY_TOKEN);
+ expect(res.status).toBe(404);
+ expect(res.body).toEqual({ error: 'Trip not found' });
+ });
+
+ it('PUBAPI-SCOPE-013: a narrowed key is stamped as used like any other', async () => {
+ await get('/api/v1/trips', TRIPS_ONLY_TOKEN);
+ const row = db
+ .prepare('SELECT last_used_at FROM mcp_tokens WHERE token_prefix = ?')
+ .get(TRIPS_ONLY_TOKEN.slice(0, 13)) as { last_used_at: string | null };
+ expect(row.last_used_at).not.toBeNull();
+ });
+ });
});
diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts
index cdef60425..9ae7d7e24 100644
--- a/server/tests/helpers/test-db.ts
+++ b/server/tests/helpers/test-db.ts
@@ -42,6 +42,10 @@ const RESET_TABLES = [
// one case into the next.
'roadtrip_day_tracks',
'roadtrip_vias',
+ // Dawarich (#2279). The suggestions reference places and bucket_list, so they
+ // are cleared before both; the connection row hangs off the user.
+ 'dawarich_visit_suggestions',
+ 'dawarich_connections',
// Day content
'day_notes',
'todo_category_assignees',
diff --git a/server/tests/unit/nest/atlas.service.test.ts b/server/tests/unit/nest/atlas.service.test.ts
index 754ebcc0c..01f774c7c 100644
--- a/server/tests/unit/nest/atlas.service.test.ts
+++ b/server/tests/unit/nest/atlas.service.test.ts
@@ -1271,9 +1271,19 @@ describe('atlas quirk fixes', () => {
const result = atlas.countryPlaces(user.id, 'JP');
- // status rides along since #1048 — a manual mark is a visit even with no trips.
- expect(result).toEqual({ places: [], trips: [], manually_marked: true, status: 'visited' });
- expect(atlas.countryPlaces(user.id, 'FR').manually_marked).toBe(false);
+ // status rides along since #1048 — a manual mark is a visit even with no
+ // trips; marked_source since #2279 says who put the mark there, and a row
+ // written before that column existed still reads as 'manual'.
+ expect(result).toEqual({
+ places: [],
+ trips: [],
+ manually_marked: true,
+ marked_source: 'manual',
+ status: 'visited',
+ });
+ const unmarked = atlas.countryPlaces(user.id, 'FR');
+ expect(unmarked.manually_marked).toBe(false);
+ expect(unmarked.marked_source).toBeNull();
});
it('ATLAS-SVC-032: updateBucketItem persists lat/lng of exactly 0 (equator/prime meridian)', () => {
diff --git a/server/tests/unit/nest/dawarich-suggestions.service.test.ts b/server/tests/unit/nest/dawarich-suggestions.service.test.ts
new file mode 100644
index 000000000..8e91fcdce
--- /dev/null
+++ b/server/tests/unit/nest/dawarich-suggestions.service.test.ts
@@ -0,0 +1,989 @@
+/**
+ * Unit tests for DawarichSuggestionsService — DAWARICH-SUG-001..030.
+ *
+ * This is the only service in the Dawarich domain that writes into TREK proper,
+ * and every write it makes crosses an ownership line: a suggestion belongs to
+ * one user, the trip it lands on belongs to a roster, the wish it ticks belongs
+ * to the caller alone. So the DB is real in-memory SQLite — the scoping lives in
+ * the SQL and a stub would assert nothing about it — while the four collaborators
+ * that would reach further (Atlas, Places, Assignments, Journey) are stubs, plus
+ * DawarichService and DawarichClient so no case can touch the network or a
+ * user's stored credentials.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+// ── DB setup (real in-memory SQLite — same pattern as the other service tests) ──
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ const mock = {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: (tripId: unknown, userId: number) =>
+ db.prepare(`
+ SELECT t.id, t.user_id FROM trips t
+ LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
+ WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
+ `).get(userId, tripId, userId),
+ isOwner: (tripId: unknown, userId: number) =>
+ !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
+ };
+ return { testDb: db, dbMock: mock };
+});
+
+vi.mock('../../../src/db/database', () => dbMock);
+vi.mock('../../../src/config', () => ({
+ JWT_SECRET: 'test-secret',
+ ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
+ updateJwtSecret: () => {},
+}));
+vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
+
+import type { DawarichConnection } from '@trek/shared';
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+import { resetTestDb } from '../../helpers/test-db';
+import { createUser, createTrip, createDay, addTripMember } from '../../helpers/factories';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+import {
+ AcceptError,
+ DawarichSuggestionsService,
+} from '../../../src/nest/integrations/dawarich-suggestions.service';
+import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
+import type { DawarichClient } from '../../../src/nest/integrations/dawarich.client';
+import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
+import type { PlacesService } from '../../../src/nest/places/places.service';
+import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
+import type { PermissionsService } from '../../../src/nest/permissions/permissions.service';
+import type { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
+
+// ── Collaborator stubs ───────────────────────────────────────────────────────
+//
+// `getCredentials` hands back a fixed pair rather than reading
+// dawarich_connections: the encrypted-at-rest round trip is DawarichService's
+// own business, and a case that needed it would be testing apiKeyCrypto. The
+// client is a stub with no implementation of its own, which doubles as the
+// assertion that nothing here reaches the network — an unstubbed call would
+// return undefined and fail loudly rather than dial out.
+
+const CREDS = { baseUrl: 'https://dawarich.example.com', apiKey: 'test-key', allowInsecureTls: false };
+
+const CONNECTION: DawarichConnection = {
+ url: CREDS.baseUrl,
+ apiKeyMasked: '********',
+ allowInsecureTls: false,
+ syncEnabled: true,
+ connected: true,
+ lastSyncAt: '2026-09-06T04:15:00Z',
+ lastSyncState: 'ok',
+ lastSyncError: null,
+ capabilities: null,
+};
+
+const dawarichStub = {
+ getConnection: vi.fn(),
+ getCredentials: vi.fn(),
+};
+const clientStub = {
+ findVisitsNear: vi.fn(),
+ listVisitedCities: vi.fn(),
+};
+const atlasStub = { markCountry: vi.fn() };
+// `broadcast` and `onCreated` are stubbed because an acceptance is a normal
+// place write and has to behave like one: everyone on the trip is told, and a
+// live journey mirrors it. Both are asserted rather than merely tolerated.
+const placesStub = { create: vi.fn(), broadcast: vi.fn(), onCreated: vi.fn() };
+const assignmentsStub = { dayExists: vi.fn(), createAssignment: vi.fn(), broadcast: vi.fn() };
+// The instance's permission matrix, stubbed so a case can narrow place_edit or
+// day_edit to the owner the way an admin would and watch a member be refused.
+const permissionsStub = { checkPermission: vi.fn() };
+const journeyStub = { canEdit: vi.fn(), createEntry: vi.fn() };
+
+const dbs = new DatabaseService(testDb);
+const svc = new DawarichSuggestionsService(
+ dbs,
+ dawarichStub as unknown as DawarichService,
+ clientStub as unknown as DawarichClient,
+ atlasStub as unknown as AtlasService,
+ placesStub as unknown as PlacesService,
+ assignmentsStub as unknown as AssignmentsService,
+ permissionsStub as unknown as PermissionsService,
+ journeyStub as unknown as JourneyDomainService,
+);
+
+const CREATED_ENTRY_ID = 7702;
+
+/**
+ * The places stub writes a real row rather than inventing an id:
+ * `accepted_place_id` is a foreign key onto places, so a made-up number makes
+ * the acceptance fail on the constraint instead of on anything a case is about.
+ */
+function insertPlaceRow(tripId: string, body: { name?: string; lat?: number; lng?: number }): { id: number } {
+ const category = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
+ const result = testDb
+ .prepare('INSERT INTO places (trip_id, name, lat, lng, category_id) VALUES (?, ?, ?, ?, ?)')
+ .run(Number(tripId), body.name ?? 'Imported stay', body.lat ?? null, body.lng ?? null, category?.id ?? null);
+ return { id: Number(result.lastInsertRowid) };
+}
+
+function armStubs(): void {
+ dawarichStub.getConnection.mockReset().mockReturnValue({ ...CONNECTION });
+ dawarichStub.getCredentials.mockReset().mockReturnValue({ ...CREDS });
+ clientStub.findVisitsNear.mockReset();
+ clientStub.listVisitedCities.mockReset();
+ // Answers true — it added the country — unless a case says otherwise.
+ atlasStub.markCountry.mockReset().mockReturnValue(true);
+ placesStub.create
+ .mockReset()
+ .mockImplementation((tripId: string, body: { name?: string; lat?: number; lng?: number }) =>
+ insertPlaceRow(tripId, body),
+ );
+ // Answered out of the same DB the cases seed: day ownership is the point of
+ // several of them, and a stub hardcoded to true would prove nothing about
+ // which trip the day was looked up against.
+ assignmentsStub.dayExists
+ .mockReset()
+ .mockImplementation(
+ (dayId: unknown, tripId: unknown) =>
+ !!testDb.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId),
+ );
+ placesStub.broadcast.mockReset();
+ placesStub.onCreated.mockReset();
+ assignmentsStub.createAssignment.mockReset();
+ assignmentsStub.broadcast.mockReset();
+ permissionsStub.checkPermission.mockReset().mockReturnValue(true);
+ journeyStub.canEdit.mockReset().mockReturnValue(true);
+ journeyStub.createEntry.mockReset().mockReturnValue({ id: CREATED_ENTRY_ID });
+}
+
+// ── Fixtures ─────────────────────────────────────────────────────────────────
+
+interface SuggestionSeed {
+ userId: number;
+ tripId?: number | null;
+ name?: string;
+ lat?: number | null;
+ lng?: number | null;
+ startedAt?: string;
+ endedAt?: string;
+ durationMinutes?: number;
+ localDate?: string;
+ state?: string;
+ matchedBucketListItemId?: number | null;
+ sourceHash?: string;
+}
+
+let _visitSeq = 0;
+
+function seedSuggestion(seed: SuggestionSeed): number {
+ _visitSeq++;
+ const result = testDb
+ .prepare(
+ `INSERT INTO dawarich_visit_suggestions
+ (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at,
+ duration_minutes, local_date, state, matched_bucket_list_item_id, source_hash)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ )
+ .run(
+ seed.userId,
+ `visit-${_visitSeq}`,
+ seed.tripId ?? null,
+ seed.name ?? 'Cafe Central',
+ seed.lat === undefined ? 48.2092 : seed.lat,
+ seed.lng === undefined ? 16.3658 : seed.lng,
+ seed.startedAt ?? '2026-09-01T09:30:00Z',
+ seed.endedAt ?? '2026-09-01T11:45:00Z',
+ seed.durationMinutes ?? 135,
+ seed.localDate ?? '2026-09-01',
+ seed.state ?? 'new',
+ seed.matchedBucketListItemId ?? null,
+ seed.sourceHash ?? `hash-${_visitSeq}`,
+ );
+ return Number(result.lastInsertRowid);
+}
+
+interface SuggestionRow {
+ id: number;
+ state: string;
+ target: string | null;
+ accepted_place_id: number | null;
+ accepted_journal_entry_id: number | null;
+ accepted_bucket_list_item_id: number | null;
+ source_hash: string;
+ accepted_hash: string | null;
+}
+
+function rowOf(id: number): SuggestionRow {
+ return testDb.prepare('SELECT * FROM dawarich_visit_suggestions WHERE id = ?').get(id) as SuggestionRow;
+}
+
+interface BucketRow {
+ id: number;
+ name: string;
+ visited_at: string | null;
+ visited_source: string | null;
+}
+
+function seedBucketItem(
+ userId: number,
+ overrides: Partial<{
+ name: string;
+ lat: number | null;
+ lng: number | null;
+ visitedAt: string | null;
+ visitedSource: string | null;
+ }> = {},
+): number {
+ const result = testDb
+ .prepare(
+ 'INSERT INTO bucket_list (user_id, name, lat, lng, visited_at, visited_source) VALUES (?, ?, ?, ?, ?, ?)',
+ )
+ .run(
+ userId,
+ overrides.name ?? 'Zell am See',
+ overrides.lat === undefined ? 47.3232 : overrides.lat,
+ overrides.lng === undefined ? 12.7981 : overrides.lng,
+ overrides.visitedAt ?? null,
+ overrides.visitedSource ?? null,
+ );
+ return Number(result.lastInsertRowid);
+}
+
+function bucketOf(id: number): BucketRow {
+ return testDb.prepare('SELECT * FROM bucket_list WHERE id = ?').get(id) as BucketRow;
+}
+
+/**
+ * The refusal itself, not just that one happened: every case here cares about
+ * the status the controller maps and the code the MCP tool renders, and
+ * `toThrow(AcceptError)` alone would pass on the wrong refusal.
+ */
+function refusalFrom(fn: () => unknown): AcceptError {
+ try {
+ fn();
+ } catch (err) {
+ if (err instanceof AcceptError) return err;
+ throw err;
+ }
+ throw new Error('expected the call to throw an AcceptError, but it returned');
+}
+
+async function asyncRefusalFrom(fn: () => Promise): Promise {
+ try {
+ await fn();
+ } catch (err) {
+ if (err instanceof AcceptError) return err;
+ throw err;
+ }
+ throw new Error('expected the call to reject with an AcceptError, but it resolved');
+}
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+ // dawarich_visit_suggestions is not in the shared helper's reset list, so it
+ // is cleared here rather than by widening a list every other suite shares.
+ testDb.exec('DELETE FROM dawarich_visit_suggestions');
+ armStubs();
+});
+
+afterAll(() => {
+ testDb.close();
+});
+
+// ── list / getOne ────────────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — the review list', () => {
+ it("DAWARICH-SUG-001: list() is scoped in SQL — a stranger's suggestion never appears", () => {
+ const { user: mine } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const mineId = seedSuggestion({ userId: mine.id, name: 'Volksgarten' });
+ const strangerId = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
+
+ const list = svc.list(mine.id, {});
+
+ expect(list.suggestions.map((s) => s.id)).toEqual([mineId]);
+ expect(list.suggestions.map((s) => s.name)).not.toContain('Their Kitchen');
+ // Asserted in both directions, so an id that happens to line up cannot carry it.
+ expect(svc.list(stranger.id, {}).suggestions.map((s) => s.id)).toEqual([strangerId]);
+ });
+
+ it('DAWARICH-SUG-002: list() narrows by trip and by state, both still inside the user scope', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const otherTrip = createTrip(testDb, user.id);
+ const strangerTrip = createTrip(testDb, stranger.id);
+
+ const onTrip = seedSuggestion({ userId: user.id, tripId: trip.id, startedAt: '2026-09-03T08:00:00Z' });
+ const dismissed = seedSuggestion({ userId: user.id, tripId: trip.id, state: 'dismissed' });
+ seedSuggestion({ userId: user.id, tripId: otherTrip.id });
+ seedSuggestion({ userId: stranger.id, tripId: strangerTrip.id });
+
+ const byTrip = svc
+ .list(user.id, { tripId: trip.id })
+ .suggestions.map((s) => s.id)
+ .sort((a, b) => a - b);
+ expect(byTrip).toEqual([onTrip, dismissed].sort((a, b) => a - b));
+
+ expect(svc.list(user.id, { tripId: trip.id, state: 'dismissed' }).suggestions.map((s) => s.id)).toEqual([
+ dismissed,
+ ]);
+ // Another user's trip id is not a way in: the user clause still applies.
+ expect(svc.list(user.id, { tripId: strangerTrip.id }).suggestions).toEqual([]);
+ });
+
+ it('DAWARICH-SUG-003: list() carries the connection state so a panel needs no second request', () => {
+ const { user } = createUser(testDb);
+ seedSuggestion({ userId: user.id });
+
+ const list = svc.list(user.id, {});
+
+ expect(dawarichStub.getConnection).toHaveBeenCalledWith(user.id);
+ expect(list.connected).toBe(true);
+ expect(list.lastSyncAt).toBe('2026-09-06T04:15:00Z');
+ expect(list.lastSyncState).toBe('ok');
+ expect(list.lastSyncError).toBeNull();
+ });
+
+ it('DAWARICH-SUG-004: getOne() answers null for a suggestion that belongs to somebody else', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const theirs = seedSuggestion({ userId: stranger.id, name: 'Their Kitchen' });
+
+ expect(svc.getOne(user.id, theirs)).toBeNull();
+ expect(svc.getOne(stranger.id, theirs)?.name).toBe('Their Kitchen');
+ });
+});
+
+// ── accept: place ────────────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — accepting into a trip', () => {
+ it('DAWARICH-SUG-005: a trip the caller cannot see is a 404, and nothing is created', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const strangerTrip = createTrip(testDb, stranger.id);
+ const id = seedSuggestion({ userId: user.id });
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: strangerTrip.id }));
+
+ expect(err.status).toBe(404);
+ expect(err.code).toBe('not_found');
+ expect(placesStub.create).not.toHaveBeenCalled();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-006: a day from another trip is a 400 — trip access is not day access', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const otherTrip = createTrip(testDb, user.id);
+ const foreignDay = createDay(testDb, otherTrip.id, { date: '2026-09-01' });
+ const id = seedSuggestion({ userId: user.id });
+
+ const err = refusalFrom(() =>
+ svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: foreignDay.id }),
+ );
+
+ expect(err.status).toBe(400);
+ expect(err.code).toBe('day_not_on_trip');
+ // The day was checked against the trip it is being attached to, not on its own.
+ expect(assignmentsStub.dayExists).toHaveBeenCalledWith(foreignDay.id, trip.id);
+ expect(placesStub.create).not.toHaveBeenCalled();
+ expect(assignmentsStub.createAssignment).not.toHaveBeenCalled();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-007: a stay on a trip the caller is a member of becomes a place pinned to the day', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id);
+ addTripMember(testDb, trip.id, member.id);
+ const day = createDay(testDb, trip.id, { date: '2026-09-01' });
+ const id = seedSuggestion({ userId: member.id, tripId: trip.id, name: 'Cafe Central' });
+
+ const result = svc.accept(member.id, id, {
+ target: 'place',
+ tripId: trip.id,
+ dayId: day.id,
+ notes: 'long lunch',
+ });
+
+ expect(placesStub.create).toHaveBeenCalledWith(
+ String(trip.id),
+ expect.objectContaining({
+ name: 'Cafe Central',
+ lat: 48.2092,
+ lng: 16.3658,
+ notes: 'long lunch',
+ place_time: '09:30',
+ end_time: '11:45',
+ duration_minutes: 135,
+ }),
+ );
+ const placeId = result.createdPlaceId;
+ expect(placeId).not.toBeNull();
+ expect(assignmentsStub.createAssignment).toHaveBeenCalledWith(day.id, placeId, null);
+ expect(result.createdJournalEntryId).toBeNull();
+ expect(result.suggestion.state).toBe('accepted');
+
+ const row = rowOf(id);
+ expect(row.target).toBe('place');
+ expect(row.accepted_place_id).toBe(placeId);
+ // The hash is frozen at acceptance, which is what makes "changed since" answerable.
+ expect(row.accepted_hash).toBe(row.source_hash);
+ expect(result.suggestion.sourceChanged).toBe(false);
+ });
+
+ it('DAWARICH-SUG-008: a stay with no trip anywhere is a 400 rather than a place nobody can see', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id, tripId: null });
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place' }));
+
+ expect(err.status).toBe(400);
+ expect(err.code).toBe('trip_required');
+ expect(placesStub.create).not.toHaveBeenCalled();
+ });
+
+ it("DAWARICH-SUG-009: accepting a stranger's suggestion is a 404 before any target is looked at", () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const theirs = seedSuggestion({ userId: stranger.id });
+
+ const err = refusalFrom(() => svc.accept(user.id, theirs, { target: 'place', tripId: trip.id }));
+
+ expect(err.status).toBe(404);
+ expect(err.code).toBe('not_found');
+ expect(placesStub.create).not.toHaveBeenCalled();
+ });
+});
+
+// ── accept: journal ──────────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — accepting into a journey', () => {
+ it('DAWARICH-SUG-010: a journey the caller may not edit refuses before createEntry is reached', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id });
+ journeyStub.canEdit.mockReturnValue(false);
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 4242 }));
+
+ expect(journeyStub.canEdit).toHaveBeenCalledWith(4242, user.id);
+ expect(err.status).toBe(404);
+ expect(err.code).toBe('journal_forbidden');
+ expect(journeyStub.createEntry).not.toHaveBeenCalled();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-011: a permitted journey gets a dated entry, with the socket id passed through', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id, name: 'Cafe Central', localDate: '2026-09-01' });
+
+ const result = svc.accept(user.id, id, { target: 'journal', journalId: 88 }, 'socket-7');
+
+ expect(journeyStub.createEntry).toHaveBeenCalledWith(
+ 88,
+ user.id,
+ expect.objectContaining({
+ type: 'entry',
+ title: 'Cafe Central',
+ entry_date: '2026-09-01',
+ entry_time: '09:30',
+ location_name: 'Cafe Central',
+ location_lat: 48.2092,
+ location_lng: 16.3658,
+ }),
+ 'socket-7',
+ );
+ expect(result.createdJournalEntryId).toBe(CREATED_ENTRY_ID);
+ expect(result.createdPlaceId).toBeNull();
+
+ const row = rowOf(id);
+ expect(row.state).toBe('accepted');
+ expect(row.target).toBe('journal');
+ expect(row.accepted_journal_entry_id).toBe(CREATED_ENTRY_ID);
+ });
+
+ it('DAWARICH-SUG-012: a journey id missing from the body is a 400, not a guess', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id });
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal' }));
+
+ expect(err.status).toBe(400);
+ expect(err.code).toBe('journal_required');
+ expect(journeyStub.canEdit).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SUG-013: a journey domain that returns nothing leaves the suggestion in review', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id });
+ journeyStub.createEntry.mockReturnValue(null);
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'journal', journalId: 12 }));
+
+ expect(err.status).toBe(404);
+ expect(rowOf(id).state).toBe('new');
+ });
+});
+
+// ── accept: bucket list ──────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — ticking off a wish', () => {
+ it('DAWARICH-SUG-014: a wish owned by somebody else is a 404 and stays untouched', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const theirWish = seedBucketItem(stranger.id, { name: 'Their Hallstatt' });
+ const id = seedSuggestion({ userId: user.id });
+
+ const err = refusalFrom(() =>
+ svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: theirWish }),
+ );
+
+ expect(err.status).toBe(404);
+ expect(err.code).toBe('not_found');
+ expect(bucketOf(theirWish).visited_at).toBeNull();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it("DAWARICH-SUG-015: the caller's own wish is ticked with the stay's arrival and marked as imported", () => {
+ const { user } = createUser(testDb);
+ const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
+ const id = seedSuggestion({ userId: user.id, startedAt: '2026-09-01T09:30:00Z' });
+
+ const result = svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
+
+ const item = bucketOf(wish);
+ expect(item.visited_at).toBe('2026-09-01T09:30:00Z');
+ expect(item.visited_source).toBe('dawarich');
+ expect(result.bucketListItemId).toBe(wish);
+ expect(result.createdPlaceId).toBeNull();
+ expect(rowOf(id).accepted_bucket_list_item_id).toBe(wish);
+ });
+
+ it('DAWARICH-SUG-016: with no wish named in the body, the matched one is used', () => {
+ const { user } = createUser(testDb);
+ const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
+ const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: wish });
+
+ const result = svc.accept(user.id, id, { target: 'bucket_list' });
+
+ expect(result.bucketListItemId).toBe(wish);
+ expect(bucketOf(wish).visited_source).toBe('dawarich');
+ });
+
+ it('DAWARICH-SUG-017: with nothing named and nothing matched it is a 400', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id, matchedBucketListItemId: null });
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'bucket_list' }));
+
+ expect(err.status).toBe(400);
+ expect(err.code).toBe('bucket_required');
+ });
+});
+
+// ── accept twice / state ─────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — what an acceptance closes off', () => {
+ it('DAWARICH-SUG-018: accepting an already accepted suggestion is a 409, not a second place', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+
+ const first = svc.accept(user.id, id, { target: 'place', tripId: trip.id });
+ expect(placesStub.create).toHaveBeenCalledTimes(1);
+
+ const err = refusalFrom(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id }));
+
+ expect(err.status).toBe(409);
+ expect(err.code).toBe('already_accepted');
+ expect(placesStub.create).toHaveBeenCalledTimes(1);
+ // Still pointing at the first place, so the refusal cost nothing and created nothing.
+ expect(rowOf(id).accepted_place_id).toBe(first.createdPlaceId);
+ expect(testDb.prepare('SELECT COUNT(*) AS n FROM places WHERE trip_id = ?').get(trip.id)).toEqual({ n: 1 });
+ });
+
+ it('DAWARICH-SUG-019: setState cannot pull an accepted suggestion back into review', () => {
+ const { user } = createUser(testDb);
+ const wish = seedBucketItem(user.id);
+ const id = seedSuggestion({ userId: user.id });
+ svc.accept(user.id, id, { target: 'bucket_list', bucketListItemId: wish });
+
+ const back = svc.setState(user.id, id, 'new');
+
+ expect(back?.state).toBe('accepted');
+ expect(rowOf(id).state).toBe('accepted');
+ // Dismissing it is refused the same way — the tick it produced is its own thing now.
+ expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('accepted');
+ expect(rowOf(id).target).toBe('bucket_list');
+ });
+
+ it('DAWARICH-SUG-020: a suggestion still in review dismisses and restores', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id });
+
+ expect(svc.setState(user.id, id, 'dismissed')?.state).toBe('dismissed');
+ expect(svc.setState(user.id, id, 'new')?.state).toBe('new');
+ });
+
+ it("DAWARICH-SUG-021: setState on a stranger's suggestion answers null and writes nothing", () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const theirs = seedSuggestion({ userId: stranger.id });
+
+ expect(svc.setState(user.id, theirs, 'dismissed')).toBeNull();
+ expect(rowOf(theirs).state).toBe('new');
+ });
+});
+
+// ── bucket-list confirmation ─────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — confirming a scan', () => {
+ it('DAWARICH-SUG-022: confirmBucketVisits only touches wishes that were not ticked yet', () => {
+ const { user } = createUser(testDb);
+ const open = seedBucketItem(user.id, { name: 'Hallstatt' });
+ const alreadyTicked = seedBucketItem(user.id, {
+ name: 'Kyoto',
+ visitedAt: '2026-01-04T12:00:00Z',
+ visitedSource: 'manual',
+ });
+
+ const updated = svc.confirmBucketVisits(user.id, [open, alreadyTicked], '2026-09-05T10:00:00Z');
+
+ expect(updated).toBe(1);
+ expect(bucketOf(open).visited_at).toBe('2026-09-05T10:00:00Z');
+ expect(bucketOf(open).visited_source).toBe('dawarich');
+ // The hand-ticked one keeps both its date and who decided it.
+ expect(bucketOf(alreadyTicked).visited_at).toBe('2026-01-04T12:00:00Z');
+ expect(bucketOf(alreadyTicked).visited_source).toBe('manual');
+ });
+
+ it("DAWARICH-SUG-023: confirmBucketVisits ignores ids that are not the caller's", () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const mine = seedBucketItem(user.id);
+ const theirs = seedBucketItem(stranger.id);
+
+ const updated = svc.confirmBucketVisits(user.id, [mine, theirs]);
+
+ expect(updated).toBe(1);
+ expect(bucketOf(theirs).visited_at).toBeNull();
+ expect(bucketOf(mine).visited_at).not.toBeNull();
+ });
+
+ it('DAWARICH-SUG-024: clearBucketVisit clears the source with the date, and only for the owner', () => {
+ const { user } = createUser(testDb);
+ const { user: stranger } = createUser(testDb);
+ const mine = seedBucketItem(user.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
+ const theirs = seedBucketItem(stranger.id, { visitedAt: '2026-09-05T10:00:00Z', visitedSource: 'dawarich' });
+
+ expect(svc.clearBucketVisit(user.id, mine)).toBe(true);
+ expect(bucketOf(mine).visited_at).toBeNull();
+ expect(bucketOf(mine).visited_source).toBeNull();
+
+ expect(svc.clearBucketVisit(user.id, theirs)).toBe(false);
+ expect(bucketOf(theirs).visited_at).toBe('2026-09-05T10:00:00Z');
+ });
+});
+
+// ── bucket-list scan (client stubbed, never the network) ─────────────────────
+
+describe('DawarichSuggestionsService — scanning for stays', () => {
+ it('DAWARICH-SUG-025: a scan without a connection refuses before a single request', async () => {
+ const { user } = createUser(testDb);
+ seedBucketItem(user.id);
+ dawarichStub.getCredentials.mockReturnValue(null);
+
+ const err = await asyncRefusalFrom(() => svc.scanBucketList(user.id));
+
+ expect(err.status).toBe(400);
+ expect(err.code).toBe('not_connected');
+ expect(clientStub.findVisitsNear).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SUG-026: the longest stay wins over the nearest, and a wish without coordinates reports as skipped', async () => {
+ const { user } = createUser(testDb);
+ const wish = seedBucketItem(user.id, { name: 'Hallstatt' });
+ seedBucketItem(user.id, { name: 'Somewhere vague', lat: null, lng: null });
+ clientStub.findVisitsNear.mockResolvedValue([
+ {
+ timestamp: 1757000000,
+ distance_meters: 12,
+ points_count: 4,
+ // The bus stopping outside: nearest, and far too short to be the visit.
+ visit_details: { start_time: '2026-09-04T10:00:00Z', end_time: '2026-09-04T10:05:00Z', duration_minutes: 5 },
+ },
+ {
+ timestamp: 1757003600,
+ distance_meters: 180,
+ points_count: 42,
+ visit_details: { start_time: '2026-09-04T12:00:00Z', end_time: '2026-09-04T13:30:00Z', duration_minutes: 90 },
+ },
+ ]);
+
+ const scan = await svc.scanBucketList(user.id);
+
+ expect(scan.skippedWithoutCoordinates).toBe(1);
+ expect(scan.truncated).toBe(false);
+ expect(scan.matches).toHaveLength(1);
+ expect(scan.matches[0]).toMatchObject({
+ itemId: wish,
+ name: 'Hallstatt',
+ alreadyVisited: false,
+ match: { at: '2026-09-04T12:00:00Z', minutes: 90, distanceMeters: 180, points: 42 },
+ });
+ });
+
+ it('DAWARICH-SUG-027: a wish whose lookup fails reads as "no match", not as a failed scan', async () => {
+ const { user } = createUser(testDb);
+ seedBucketItem(user.id, { name: 'Hallstatt' });
+ clientStub.findVisitsNear.mockRejectedValue(new Error('upstream is down'));
+
+ const scan = await svc.scanBucketList(user.id);
+
+ expect(scan.matches).toHaveLength(1);
+ expect(scan.matches[0].match).toBeNull();
+ });
+});
+
+// ── Atlas ────────────────────────────────────────────────────────────────────
+
+describe('DawarichSuggestionsService — the Atlas hand-off', () => {
+ it('DAWARICH-SUG-028: acceptAtlasCountries marks every code through the Atlas with the dawarich source', () => {
+ const { user } = createUser(testDb);
+
+ const marked = svc.acceptAtlasCountries(user.id, ['de', ' fr ', 'us']);
+
+ expect(marked).toBe(3);
+ expect(atlasStub.markCountry).toHaveBeenCalledTimes(3);
+ expect(atlasStub.markCountry).toHaveBeenNthCalledWith(1, user.id, 'DE', 'dawarich');
+ expect(atlasStub.markCountry).toHaveBeenNthCalledWith(2, user.id, 'FR', 'dawarich');
+ expect(atlasStub.markCountry).toHaveBeenNthCalledWith(3, user.id, 'US', 'dawarich');
+ });
+
+ it('DAWARICH-SUG-042: the count is what was added, not what was asked for', () => {
+ const { user } = createUser(testDb);
+ // A country already on the map is an INSERT OR IGNORE that changes nothing,
+ // and "2 countries added" for one country is a toast that lies.
+ atlasStub.markCountry.mockImplementation((_userId: number, code: string) => code === 'FR');
+
+ expect(svc.acceptAtlasCountries(user.id, ['de', 'fr'])).toBe(1);
+ });
+
+ it('DAWARICH-SUG-029: anything that is not a two-letter code is skipped rather than written', () => {
+ const { user } = createUser(testDb);
+
+ const marked = svc.acceptAtlasCountries(user.id, ['XYZ', 'D', '', ' ', 'D1', 'at']);
+
+ expect(marked).toBe(1);
+ expect(atlasStub.markCountry).toHaveBeenCalledTimes(1);
+ expect(atlasStub.markCountry).toHaveBeenCalledWith(user.id, 'AT', 'dawarich');
+ });
+
+ it('DAWARICH-SUG-030: atlasSuggestions codes what it can, lists what it cannot, and flags what TREK already has', async () => {
+ const { user } = createUser(testDb);
+ testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
+ clientStub.listVisitedCities.mockResolvedValue([
+ { country: 'Germany', cities: [{ city: 'Berlin', stayed_for: 120, timestamp: 1757000000 }] },
+ { country: 'France', cities: [] },
+ { country: 'Absurdistan', cities: [] },
+ ]);
+
+ const suggestions = await svc.atlasSuggestions(user.id, new Date('2026-08-01'), new Date('2026-09-01'));
+
+ expect(suggestions.countries.map((c) => c.countryCode)).toEqual(['DE', 'FR']);
+ expect(suggestions.countries[0]).toMatchObject({ sourceName: 'Germany', alreadyVisited: true });
+ expect(suggestions.countries[0].cities[0]).toMatchObject({ name: 'Berlin', minutes: 120 });
+ expect(suggestions.countries[1].alreadyVisited).toBe(false);
+ // A country TREK cannot code is still a country the user went to.
+ expect(suggestions.unresolved).toEqual(['Absurdistan']);
+ });
+ // ── What an acceptance tells the rest of the trip ──────────────────────────
+
+ it('DAWARICH-SUG-031: accepting as a place broadcasts it like any other place, socket id and all', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id, name: 'Museum Ludwig' });
+
+ const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id }, 'socket-7');
+
+ expect(placesStub.broadcast).toHaveBeenCalledWith(
+ String(trip.id),
+ 'place:created',
+ { place: expect.objectContaining({ id: result.createdPlaceId }) },
+ 'socket-7',
+ );
+ // The journey mirror is the other half of what the places controller does.
+ expect(placesStub.onCreated).toHaveBeenCalledWith(String(trip.id), result.createdPlaceId);
+ });
+
+ it('DAWARICH-SUG-039: a member refused place_edit is refused here too', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id);
+ addTripMember(testDb, trip.id, member.id);
+ const id = seedSuggestion({ userId: member.id, tripId: trip.id });
+ permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'place_edit');
+
+ expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id })).toThrow(AcceptError);
+ // Nothing written, and the stay is still waiting rather than marked handled.
+ expect(placesStub.create).not.toHaveBeenCalled();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-040: pinning to a day asks day_edit as well, and writes nothing without it', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id);
+ addTripMember(testDb, trip.id, member.id);
+ const day = createDay(testDb, trip.id, { date: '2026-09-01' });
+ const id = seedSuggestion({ userId: member.id, tripId: trip.id });
+ permissionsStub.checkPermission.mockImplementation((action: string) => action !== 'day_edit');
+
+ expect(() => svc.accept(member.id, id, { target: 'place', tripId: trip.id, dayId: day.id })).toThrow(AcceptError);
+ expect(placesStub.create).not.toHaveBeenCalled();
+ expect(rowOf(id).state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-041: the permission is asked about the trip owner, not about the caller', () => {
+ const { user: owner } = createUser(testDb);
+ const { user: member } = createUser(testDb);
+ const trip = createTrip(testDb, owner.id);
+ addTripMember(testDb, trip.id, member.id);
+ const id = seedSuggestion({ userId: member.id, tripId: trip.id });
+
+ svc.accept(member.id, id, { target: 'place', tripId: trip.id });
+
+ expect(permissionsStub.checkPermission).toHaveBeenCalledWith('place_edit', 'user', owner.id, member.id, true);
+ });
+
+ it('DAWARICH-SUG-038: a place made from a recording says where it came from', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+
+ const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id });
+
+ const place = testDb
+ .prepare('SELECT source FROM places WHERE id = ?')
+ .get(result.createdPlaceId) as { source: string | null };
+ expect(place.source).toBe('dawarich');
+ });
+
+ it('DAWARICH-SUG-032: a stay accepted onto a day broadcasts the assignment too', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id, { date: '2026-09-01' });
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+ assignmentsStub.createAssignment.mockReturnValue({ id: 4242, day_id: day.id });
+
+ svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id }, 'socket-9');
+
+ expect(assignmentsStub.broadcast).toHaveBeenCalledWith(
+ String(trip.id),
+ 'assignment:created',
+ { assignment: { id: 4242, day_id: day.id } },
+ 'socket-9',
+ );
+ });
+
+ it('DAWARICH-SUG-033: an assignment the domain refuses to create is not broadcast as one', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const day = createDay(testDb, trip.id, { date: '2026-09-01' });
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+ assignmentsStub.createAssignment.mockReturnValue(undefined);
+
+ svc.accept(user.id, id, { target: 'place', tripId: trip.id, dayId: day.id });
+
+ expect(assignmentsStub.broadcast).not.toHaveBeenCalled();
+ });
+
+ // ── An acceptance whose result was deleted ────────────────────────────────
+ //
+ // Deleting the place is how somebody says "not that one" after the fact. The
+ // stay then has nothing to show for itself and belongs back in the review
+ // list; leaving it under "already dealt with" is a dead end, because the row
+ // points at a place that is gone and accepting it again is refused as a
+ // duplicate.
+
+ it('DAWARICH-SUG-034: deleting the place an acceptance created puts the stay back into review', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+ const result = svc.accept(user.id, id, { target: 'place', tripId: trip.id });
+ expect(rowOf(id).state).toBe('accepted');
+
+ testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
+
+ const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!;
+ expect(listed.state).toBe('new');
+ expect(listed.target).toBeNull();
+ expect(listed.acceptedPlaceId).toBeNull();
+ // And it can be accepted again rather than answering 409 forever.
+ expect(() => svc.accept(user.id, id, { target: 'place', tripId: trip.id })).not.toThrow();
+ });
+
+ it('DAWARICH-SUG-035: an acceptance whose place still exists is left alone', () => {
+ const { user } = createUser(testDb);
+ const trip = createTrip(testDb, user.id);
+ const id = seedSuggestion({ userId: user.id, tripId: trip.id });
+ svc.accept(user.id, id, { target: 'place', tripId: trip.id });
+
+ const listed = svc.list(user.id, {}).suggestions.find((s) => s.id === id)!;
+ expect(listed.state).toBe('accepted');
+ expect(listed.acceptedPlaceId).not.toBeNull();
+ });
+
+ it('DAWARICH-SUG-036: a journal acceptance reopens once its entry is gone', () => {
+ const { user } = createUser(testDb);
+ const id = seedSuggestion({ userId: user.id, tripId: null });
+ const journeyId = Number(
+ testDb
+ .prepare(
+ "INSERT INTO journeys (user_id, title, status, created_at, updated_at) VALUES (?, 'Trip diary', 'active', 0, 0)",
+ )
+ .run(user.id).lastInsertRowid,
+ );
+ const entryId = Number(
+ testDb
+ .prepare(
+ "INSERT INTO journey_entries (journey_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, 'entry', 'Cafe', '2026-09-01', 0, 0)",
+ )
+ .run(journeyId, user.id).lastInsertRowid,
+ );
+ journeyStub.createEntry.mockReturnValue({ id: entryId });
+
+ svc.accept(user.id, id, { target: 'journal', journalId: journeyId });
+ expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('accepted');
+
+ testDb.prepare('DELETE FROM journey_entries WHERE id = ?').run(entryId);
+
+ expect(svc.list(user.id, {}).suggestions.find((s) => s.id === id)!.state).toBe('new');
+ });
+
+ it('DAWARICH-SUG-037: reopening is scoped to the caller — another user\'s orphan stays put', () => {
+ const { user: mine } = createUser(testDb);
+ const { user: theirs } = createUser(testDb);
+ const trip = createTrip(testDb, theirs.id);
+ const id = seedSuggestion({ userId: theirs.id, tripId: trip.id });
+ const result = svc.accept(theirs.id, id, { target: 'place', tripId: trip.id });
+ testDb.prepare('DELETE FROM places WHERE id = ?').run(result.createdPlaceId);
+
+ svc.list(mine.id, {});
+
+ expect(rowOf(id).state).toBe('accepted');
+ });
+});
diff --git a/server/tests/unit/nest/dawarich-sync.service.test.ts b/server/tests/unit/nest/dawarich-sync.service.test.ts
new file mode 100644
index 000000000..4dbf9ec66
--- /dev/null
+++ b/server/tests/unit/nest/dawarich-sync.service.test.ts
@@ -0,0 +1,828 @@
+/**
+ * DawarichSyncService against a real in-memory database.
+ *
+ * The service is almost entirely reconciliation SQL — insert-or-update keyed on
+ * (user, source visit id), a hash comparison that must not touch an accepted
+ * row, and a "what did the source stop listing" pass over a window. A mocked
+ * DatabaseService would assert that the right strings were handed along and
+ * prove nothing about what they do, so the schema comes from the real migration
+ * array and the rows are read back with plain SQL.
+ *
+ * The two collaborators are faked rather than built: the client because a unit
+ * test must not open a socket, and DawarichService because its credential path
+ * drags in at-rest crypto and its capability probe makes five more upstream
+ * calls that have nothing to do with reconciliation. The fake still writes
+ * `recordSyncResult` into `dawarich_connections`, so "the failure is stored" is
+ * asserted against the table the settings card reads, not against a spy alone.
+ */
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+
+// ── DB setup (real in-memory SQLite — same vi.hoisted pattern as atlas/immich) ──
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ return {
+ testDb: db,
+ dbMock: {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ getPlaceWithTags: () => null,
+ canAccessTrip: () => null,
+ isOwner: () => false,
+ },
+ };
+});
+
+vi.mock('../../../src/db/database', () => dbMock);
+
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
+import { createUser, createTrip } from '../../helpers/factories';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+import { AddonsService } from '../../../src/nest/addons/addons.service';
+import { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
+import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
+import type { DawarichClient, DawarichCreds, DawarichVisitRaw } from '../../../src/nest/integrations/dawarich.client';
+import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
+import type { DawarichCapabilities, DawarichSyncState } from '@trek/shared';
+
+// ── Fakes ────────────────────────────────────────────────────────────────────
+
+const CAPABILITIES: DawarichCapabilities = {
+ visits: true,
+ tracks: true,
+ points: true,
+ locations: false,
+ visitedCities: true,
+ visitUpdatedAt: false,
+ visitCountryCode: false,
+ serverVersion: '1.14.4',
+ probedAt: '2026-09-12T00:00:00.000Z',
+};
+
+const listVisits = vi.fn();
+const client = { listVisits } as unknown as DawarichClient;
+
+/** Reads the same two columns the real service reads, so a row without a key is null. */
+const getCredentials = vi.fn((userId: number): DawarichCreds | null => {
+ const row = testDb
+ .prepare('SELECT url, api_key, allow_insecure_tls FROM dawarich_connections WHERE user_id = ?')
+ .get(userId) as { url: string | null; api_key: string | null; allow_insecure_tls: number } | undefined;
+ if (!row?.url || !row?.api_key) return null;
+ return { baseUrl: row.url, apiKey: row.api_key, allowInsecureTls: !!row.allow_insecure_tls };
+});
+
+/** Writes the result where the settings card reads it, exactly as the real service does. */
+const recordSyncResult = vi.fn((userId: number, state: DawarichSyncState, error: string | null): void => {
+ testDb
+ .prepare(
+ 'UPDATE dawarich_connections SET last_sync_at = ?, last_sync_state = ?, last_sync_error = ? WHERE user_id = ?',
+ )
+ .run(new Date().toISOString(), state, error, userId);
+});
+
+const listSyncableUserIds = vi.fn((): number[] =>
+ (
+ testDb
+ .prepare(
+ "SELECT user_id FROM dawarich_connections WHERE sync_enabled = 1 AND url IS NOT NULL AND url <> '' AND api_key IS NOT NULL",
+ )
+ .all() as { user_id: number }[]
+ ).map((r) => r.user_id),
+);
+
+const storeCapabilities = vi.fn();
+const probeCapabilities = vi.fn(async (): Promise => CAPABILITIES);
+
+const dawarich = {
+ listSyncableUserIds,
+ getCredentials,
+ recordSyncResult,
+ storeCapabilities,
+ probeCapabilities,
+} as unknown as DawarichService;
+
+// Direct construction over the shared test connection — no TestingModule
+// (repo convention for DI-native service unit tests).
+const dbs = new DatabaseService(testDb);
+const addons = new AddonsService(dbs);
+const svc = new DawarichSyncService(dbs, addons, client, dawarich);
+
+// ── Fixtures ─────────────────────────────────────────────────────────────────
+
+const DAY_MS = 86_400_000;
+
+/** A calendar date relative to today, so a fixture never ages out of the 400-day trip cut-off. */
+function dayOffset(days: number): string {
+ return new Date(Date.now() + days * DAY_MS).toISOString().slice(0, 10);
+}
+
+/** The trip every case uses: finished, recent, comfortably inside the sync window. */
+const TRIP_START = dayOffset(-30);
+const TRIP_END = dayOffset(-25);
+/** A day inside that trip — every visit fixture happens here unless it says otherwise. */
+const VISIT_DAY = dayOffset(-28);
+
+/** Brandenburger Tor. Berlin throughout, so the resolved country is unambiguous. */
+const LAT = 52.5163;
+const LNG = 13.3777;
+
+let USER = 0;
+let TRIP = 0;
+
+function visit(over: Partial & { id: number | string }): DawarichVisitRaw {
+ return {
+ id: over.id,
+ area_id: null,
+ started_at: over.started_at ?? `${VISIT_DAY}T09:00:00Z`,
+ ended_at: over.ended_at ?? `${VISIT_DAY}T12:00:00Z`,
+ duration: over.duration ?? null,
+ name: over.name ?? 'Hotel Adlon',
+ status: over.status ?? 'suggested',
+ confidence: over.confidence ?? null,
+ confidence_band: over.confidence_band ?? null,
+ place: over.place !== undefined ? over.place : { latitude: LAT, longitude: LNG, id: 77 },
+ };
+}
+
+interface SuggestionRow {
+ id: number;
+ user_id: number;
+ source_visit_id: string;
+ trip_id: number | null;
+ name: string;
+ lat: number | null;
+ lng: number | null;
+ started_at: string;
+ ended_at: string;
+ duration_minutes: number;
+ local_date: string;
+ source_status: string;
+ confidence: number | null;
+ confidence_band: string | null;
+ country_code: string | null;
+ state: string;
+ source_hash: string;
+ accepted_hash: string | null;
+ source_missing_at: string | null;
+ matched_bucket_list_item_id: number | null;
+ first_seen_at: string;
+ last_seen_at: string;
+}
+
+function rows(userId = USER): SuggestionRow[] {
+ return testDb
+ .prepare('SELECT * FROM dawarich_visit_suggestions WHERE user_id = ? ORDER BY id')
+ .all(userId) as SuggestionRow[];
+}
+
+function only(userId = USER): SuggestionRow {
+ const all = rows(userId);
+ expect(all).toHaveLength(1);
+ return all[0];
+}
+
+function connection(userId = USER): { last_sync_state: string; last_sync_error: string | null } {
+ return testDb
+ .prepare('SELECT last_sync_state, last_sync_error FROM dawarich_connections WHERE user_id = ?')
+ .get(userId) as { last_sync_state: string; last_sync_error: string | null };
+}
+
+function connect(
+ userId: number,
+ opts: { url?: string | null; apiKey?: string | null; syncEnabled?: boolean } = {},
+): void {
+ testDb
+ .prepare(
+ 'INSERT OR REPLACE INTO dawarich_connections (user_id, url, api_key, allow_insecure_tls, sync_enabled) VALUES (?, ?, ?, 0, ?)',
+ )
+ .run(
+ userId,
+ opts.url === undefined ? 'https://dawarich.test' : opts.url,
+ opts.apiKey === undefined ? 'secret-key' : opts.apiKey,
+ opts.syncEnabled === false ? 0 : 1,
+ );
+}
+
+function bucketItem(name: string, lat: number, lng: number, userId = USER): number {
+ const res = testDb
+ .prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, ?, ?)')
+ .run(userId, name, lat, lng);
+ return Number(res.lastInsertRowid);
+}
+
+/** Every window of the next run answers with exactly these visits. */
+function withVisits(...visits: DawarichVisitRaw[]): void {
+ listVisits.mockResolvedValue({ visits, truncated: false, version: '1.14.4' });
+}
+
+beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+});
+
+beforeEach(() => {
+ resetTestDb(testDb);
+ // RESET_TABLES in tests/helpers/test-db.ts predates this domain and does not
+ // list its two tables; foreign keys are off during the reset, so rows would
+ // otherwise outlive their user and leak into the next case.
+ testDb.exec('DELETE FROM dawarich_visit_suggestions');
+ testDb.exec('DELETE FROM dawarich_connections');
+ vi.clearAllMocks();
+ probeCapabilities.mockResolvedValue(CAPABILITIES);
+
+ setAddonEnabled(testDb, 'dawarich', true);
+ USER = createUser(testDb).user.id;
+ TRIP = createTrip(testDb, USER, { start_date: TRIP_START, end_date: TRIP_END }).id;
+ connect(USER);
+});
+
+afterAll(() => {
+ testDb.close();
+});
+
+// ── Creating suggestions ─────────────────────────────────────────────────────
+
+describe('DawarichSyncService — new visits', () => {
+ it('DAWARICH-SYNC-001: stores an unseen visit as a suggestion in state "new"', async () => {
+ withVisits(visit({ id: 501, name: 'Hotel Adlon', confidence: 0.82, confidence_band: 'high' }));
+
+ const result = await svc.syncUser(USER);
+
+ expect(result).toMatchObject({ state: 'ok', created: 1, updated: 0, missing: 0 });
+ const row = only();
+ expect(row.source_visit_id).toBe('501');
+ expect(row.state).toBe('new');
+ expect(row.trip_id).toBe(TRIP);
+ expect(row.name).toBe('Hotel Adlon');
+ expect(row.lat).toBeCloseTo(LAT, 4);
+ expect(row.lng).toBeCloseTo(LNG, 4);
+ expect(row.duration_minutes).toBe(180);
+ expect(row.local_date).toBe(VISIT_DAY);
+ expect(row.source_status).toBe('suggested');
+ expect(row.confidence).toBe(0.82);
+ expect(row.confidence_band).toBe('high');
+ expect(row.source_hash).toMatch(/^[0-9a-f]{64}$/);
+ expect(row.source_missing_at).toBeNull();
+ expect(row.accepted_hash).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-002: resolves the country from the coordinates when the source sends none', async () => {
+ withVisits(visit({ id: 502 }));
+
+ await svc.syncUser(USER);
+
+ expect(only().country_code).toBe('DE');
+ });
+
+ it('DAWARICH-SYNC-003: prefers the country code the source sends over the resolved one', async () => {
+ withVisits(visit({ id: 503, place: { latitude: LAT, longitude: LNG, id: 77, country_code: 'at' } }));
+
+ await svc.syncUser(USER);
+
+ expect(only().country_code).toBe('AT');
+ });
+
+ it('DAWARICH-SYNC-004: skips a payload entry that is not a usable visit', async () => {
+ withVisits(visit({ id: 504 }), {
+ id: 505,
+ area_id: null,
+ started_at: 'not-a-date',
+ ended_at: 'nope',
+ duration: null,
+ name: 'Broken',
+ status: null,
+ confidence: null,
+ confidence_band: null,
+ place: null,
+ });
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.created).toBe(1);
+ expect(rows()).toHaveLength(1);
+ });
+});
+
+// ── Idempotency and change detection ─────────────────────────────────────────
+
+describe('DawarichSyncService — repeated runs', () => {
+ it('DAWARICH-SYNC-010: a second run over identical data creates no duplicate', async () => {
+ withVisits(visit({ id: 601 }));
+ await svc.syncUser(USER);
+ const first = only();
+
+ const second = await svc.syncUser(USER);
+
+ expect(second).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 });
+ const row = only();
+ expect(row.id).toBe(first.id);
+ expect(row.first_seen_at).toBe(first.first_seen_at);
+ expect(row.source_hash).toBe(first.source_hash);
+ });
+
+ it('DAWARICH-SYNC-011: a changed visit rewrites a suggestion still in state "new"', async () => {
+ withVisits(visit({ id: 602, name: 'Unnamed place' }));
+ await svc.syncUser(USER);
+ const before = only();
+
+ withVisits(visit({ id: 602, name: 'Café Einstein', place: { latitude: 52.52, longitude: 13.38, id: 78 } }));
+ const second = await svc.syncUser(USER);
+
+ expect(second).toMatchObject({ created: 0, updated: 1 });
+ const row = only();
+ expect(row.id).toBe(before.id);
+ expect(row.name).toBe('Café Einstein');
+ expect(row.lat).toBeCloseTo(52.52, 4);
+ expect(row.source_hash).not.toBe(before.source_hash);
+ expect(row.state).toBe('new');
+ });
+
+ it('DAWARICH-SYNC-012: a changed visit in state "accepted" keeps the user text and only moves the hash', async () => {
+ withVisits(visit({ id: 603, name: 'Hotel Adlon' }));
+ await svc.syncUser(USER);
+ const before = only();
+
+ // What acceptance leaves behind: the user's own wording plus the hash they said yes to.
+ testDb
+ .prepare(
+ "UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash, name = ? WHERE id = ?",
+ )
+ .run('Our anniversary dinner', before.id);
+
+ withVisits(visit({ id: 603, name: 'Adlon Kempinski', place: { latitude: 52.4, longitude: 13.2, id: 79 } }));
+ const second = await svc.syncUser(USER);
+
+ expect(second).toMatchObject({ created: 0, updated: 1 });
+ const row = only();
+ expect(row.state).toBe('accepted');
+ expect(row.name).toBe('Our anniversary dinner');
+ expect(row.lat).toBeCloseTo(LAT, 4);
+ // sourceChanged is exactly this inequality, and the hash is the only thing that moved.
+ expect(row.source_hash).not.toBe(before.source_hash);
+ expect(row.accepted_hash).toBe(before.source_hash);
+ expect(row.source_hash).not.toBe(row.accepted_hash);
+ });
+
+ it('DAWARICH-SYNC-013: a changed visit in state "dismissed" is likewise left alone', async () => {
+ withVisits(visit({ id: 604, name: 'Petrol station' }));
+ await svc.syncUser(USER);
+ const before = only();
+ testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'dismissed' WHERE id = ?").run(before.id);
+
+ withVisits(visit({ id: 604, name: 'Aral Tankstelle' }));
+ const second = await svc.syncUser(USER);
+
+ expect(second.updated).toBe(1);
+ const row = only();
+ expect(row.state).toBe('dismissed');
+ expect(row.name).toBe('Petrol station');
+ expect(row.source_hash).not.toBe(before.source_hash);
+ });
+
+ it('DAWARICH-SYNC-014: an unchanged accepted row is not counted as an update', async () => {
+ withVisits(visit({ id: 605 }));
+ await svc.syncUser(USER);
+ testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted', accepted_hash = source_hash").run();
+
+ const second = await svc.syncUser(USER);
+
+ expect(second).toMatchObject({ created: 0, updated: 0, missing: 0 });
+ });
+});
+
+// ── Disappearance ────────────────────────────────────────────────────────────
+
+describe('DawarichSyncService — visits that vanish from the source', () => {
+ it('DAWARICH-SYNC-020: an untouched suggestion is deleted when the source stops listing it', async () => {
+ withVisits(visit({ id: 701 }));
+ await svc.syncUser(USER);
+ expect(rows()).toHaveLength(1);
+
+ withVisits();
+ const second = await svc.syncUser(USER);
+
+ expect(second).toMatchObject({ created: 0, updated: 0, missing: 1 });
+ expect(rows()).toHaveLength(0);
+ });
+
+ it('DAWARICH-SYNC-021: an accepted suggestion survives and is only stamped source_missing_at', async () => {
+ withVisits(
+ visit({ id: 702 }),
+ visit({
+ id: 703,
+ started_at: `${VISIT_DAY}T14:00:00Z`,
+ ended_at: `${VISIT_DAY}T16:00:00Z`,
+ name: 'Museumsinsel',
+ }),
+ );
+ await svc.syncUser(USER);
+ testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '703'").run();
+
+ withVisits();
+ const second = await svc.syncUser(USER);
+
+ expect(second.missing).toBe(2);
+ const surviving = only();
+ expect(surviving.source_visit_id).toBe('703');
+ expect(surviving.state).toBe('accepted');
+ expect(surviving.name).toBe('Museumsinsel');
+ expect(surviving.source_missing_at).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+
+ it('DAWARICH-SYNC-022: a second empty run does not move an existing source_missing_at', async () => {
+ withVisits(visit({ id: 704 }));
+ await svc.syncUser(USER);
+ testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '704'").run();
+
+ withVisits();
+ await svc.syncUser(USER);
+ const firstStamp = only().source_missing_at;
+ expect(firstStamp).not.toBeNull();
+
+ await svc.syncUser(USER);
+
+ expect(only().source_missing_at).toBe(firstStamp);
+ });
+
+ it('DAWARICH-SYNC-023: a visit that comes back clears the missing flag', async () => {
+ withVisits(visit({ id: 705 }));
+ await svc.syncUser(USER);
+ testDb.prepare("UPDATE dawarich_visit_suggestions SET state = 'accepted' WHERE source_visit_id = '705'").run();
+
+ withVisits();
+ await svc.syncUser(USER);
+ expect(only().source_missing_at).not.toBeNull();
+
+ withVisits(visit({ id: 705 }));
+ await svc.syncUser(USER);
+
+ expect(only().source_missing_at).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-024: a suggestion outside the fetched window is untouched by the reconciliation', async () => {
+ withVisits(visit({ id: 706 }));
+ await svc.syncUser(USER);
+
+ // Same user and trip, but a start date years before the window this trip asks about.
+ testDb
+ .prepare(
+ `INSERT INTO dawarich_visit_suggestions
+ (user_id, source_visit_id, trip_id, name, lat, lng, started_at, ended_at, duration_minutes,
+ local_date, source_status, state, source_hash)
+ VALUES (?, '999', ?, 'Ancient stay', ?, ?, '2019-01-01T10:00:00Z', '2019-01-01T12:00:00Z', 120,
+ '2019-01-01', 'suggested', 'new', 'deadbeef')`,
+ )
+ .run(USER, TRIP, LAT, LNG);
+
+ withVisits();
+ const second = await svc.syncUser(USER);
+
+ expect(second.missing).toBe(1);
+ expect(only().source_visit_id).toBe('999');
+ });
+});
+
+// ── Bucket-list matching ─────────────────────────────────────────────────────
+
+describe('DawarichSyncService — bucket-list matching', () => {
+ it('DAWARICH-SYNC-030: links a wish that is both close enough and dwelt on long enough', async () => {
+ const wish = bucketItem('Brandenburger Tor', LAT + 0.001, LNG); // ~111 m
+ withVisits(visit({ id: 801, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T10:00:00Z` }));
+
+ await svc.syncUser(USER);
+
+ expect(only().matched_bucket_list_item_id).toBe(wish);
+ });
+
+ it('DAWARICH-SYNC-031: refuses a wish that is close but whose stay is too short', async () => {
+ bucketItem('Brandenburger Tor', LAT + 0.001, LNG);
+ // Ten minutes, under DAWARICH_BUCKET_MATCH_MIN_MINUTES.
+ withVisits(visit({ id: 802, started_at: `${VISIT_DAY}T09:00:00Z`, ended_at: `${VISIT_DAY}T09:10:00Z` }));
+
+ await svc.syncUser(USER);
+
+ const row = only();
+ expect(row.duration_minutes).toBe(10);
+ expect(row.matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-032: refuses a long stay that is inside the coarse box but beyond the radius', async () => {
+ bucketItem('Reichstag', LAT + 0.008, LNG); // ~890 m: inside the prefilter box, outside 250 m
+ withVisits(visit({ id: 803 }));
+
+ await svc.syncUser(USER);
+
+ expect(only().matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-033: picks the nearest wish when several are in range', async () => {
+ const far = bucketItem('Pariser Platz', LAT + 0.002, LNG); // ~222 m
+ const near = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG); // ~56 m
+
+ withVisits(visit({ id: 804 }));
+ await svc.syncUser(USER);
+
+ const matched = only().matched_bucket_list_item_id;
+ expect(matched).toBe(near);
+ expect(matched).not.toBe(far);
+ });
+
+ it('DAWARICH-SYNC-034: ignores a wish belonging to another user', async () => {
+ const other = createUser(testDb).user.id;
+ bucketItem('Brandenburger Tor', LAT + 0.0005, LNG, other);
+
+ withVisits(visit({ id: 805 }));
+ await svc.syncUser(USER);
+
+ expect(only().matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-035: a visit without coordinates matches nothing', async () => {
+ bucketItem('Brandenburger Tor', LAT, LNG);
+ withVisits(visit({ id: 806, place: null }));
+
+ await svc.syncUser(USER);
+
+ const row = only();
+ expect(row.lat).toBeNull();
+ expect(row.matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-037: one wish, one stay — the nearer of two neighbours keeps it', async () => {
+ // 250 m is a city block, and a block in a city centre holds a dozen stays.
+ // The café across the square from the museum satisfies the radius exactly as
+ // the museum does; showing the same wish on both turns one achievement into
+ // two claims and invites ticking it off from the wrong one.
+ const wish = bucketItem('Museum Ludwig', LAT, LNG);
+ withVisits(
+ visit({ id: 810, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }),
+ visit({ id: 811, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }),
+ );
+
+ await svc.syncUser(USER);
+
+ const all = rows();
+ expect(all.find((r) => r.source_visit_id === '811')!.matched_bucket_list_item_id).toBe(wish);
+ expect(all.find((r) => r.source_visit_id === '810')!.matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-038: the order the visits arrive in does not decide who keeps the wish', async () => {
+ const wish = bucketItem('Museum Ludwig', LAT, LNG);
+ withVisits(
+ visit({ id: 821, name: 'Museum Ludwig', place: { latitude: LAT + 0.0002, longitude: LNG, id: 2 } }),
+ visit({ id: 822, name: 'Cafe Reichard', place: { latitude: LAT + 0.0018, longitude: LNG, id: 1 } }),
+ );
+
+ await svc.syncUser(USER);
+
+ const all = rows();
+ expect(all.find((r) => r.source_visit_id === '821')!.matched_bucket_list_item_id).toBe(wish);
+ expect(all.find((r) => r.source_visit_id === '822')!.matched_bucket_list_item_id).toBeNull();
+ });
+
+ it('DAWARICH-SYNC-036: a suggestion still in state "new" is re-matched on a later run', async () => {
+ withVisits(visit({ id: 807 }));
+ await svc.syncUser(USER);
+ expect(only().matched_bucket_list_item_id).toBeNull();
+
+ const wish = bucketItem('Brandenburger Tor', LAT + 0.0005, LNG);
+ withVisits(visit({ id: 807, name: 'Brandenburg Gate' }));
+ await svc.syncUser(USER);
+
+ expect(only().matched_bucket_list_item_id).toBe(wish);
+ });
+});
+
+// ── syncUser result states ───────────────────────────────────────────────────
+
+describe('DawarichSyncService — syncUser result state', () => {
+ /** A second, older trip so one run covers two windows. */
+ function secondTrip(): number {
+ return createTrip(testDb, USER, { start_date: dayOffset(-60), end_date: dayOffset(-55) }).id;
+ }
+
+ it('DAWARICH-SYNC-040: reports "partial" when one of two trips fails', async () => {
+ secondTrip();
+ listVisits
+ .mockRejectedValueOnce(new DawarichError('unreachable', 'connect ECONNREFUSED'))
+ .mockResolvedValueOnce({ visits: [visit({ id: 901 })], truncated: false, version: null });
+
+ const result = await svc.syncUser(USER);
+
+ expect(listVisits).toHaveBeenCalledTimes(2);
+ expect(result.state).toBe('partial');
+ expect(recordSyncResult).toHaveBeenCalledWith(USER, 'partial', 'unreachable');
+ expect(connection()).toMatchObject({ last_sync_state: 'partial', last_sync_error: 'unreachable' });
+ });
+
+ it('DAWARICH-SYNC-041: reports "failed" when every trip fails', async () => {
+ secondTrip();
+ listVisits.mockRejectedValue(new DawarichError('unauthorized', 'bad key'));
+
+ const result = await svc.syncUser(USER);
+
+ expect(listVisits).toHaveBeenCalledTimes(2);
+ expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 });
+ expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'unauthorized' });
+ });
+
+ it('DAWARICH-SYNC-042: a throw that is not a DawarichError is recorded as "unreachable"', async () => {
+ listVisits.mockRejectedValue(new TypeError('fetch failed'));
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.state).toBe('failed');
+ expect(connection().last_sync_error).toBe('unreachable');
+ });
+
+ it('DAWARICH-SYNC-043: reports "ok" and clears the stored error on a clean run', async () => {
+ testDb
+ .prepare(
+ "UPDATE dawarich_connections SET last_sync_state = 'failed', last_sync_error = 'unreachable' WHERE user_id = ?",
+ )
+ .run(USER);
+ withVisits(visit({ id: 902 }));
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.state).toBe('ok');
+ expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null });
+ });
+
+ it('DAWARICH-SYNC-044: a user with no syncable trip is "ok", not a failure', async () => {
+ testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP);
+
+ const result = await svc.syncUser(USER);
+
+ expect(result).toMatchObject({ state: 'ok', created: 0, updated: 0, missing: 0 });
+ expect(listVisits).not.toHaveBeenCalled();
+ expect(connection()).toMatchObject({ last_sync_state: 'ok', last_sync_error: null });
+ });
+
+ it('DAWARICH-SYNC-045: an archived trip is not polled', async () => {
+ testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(TRIP);
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.state).toBe('ok');
+ expect(listVisits).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-046: a trip the user is only a member of is polled too', async () => {
+ const owner = createUser(testDb).user.id;
+ const shared = createTrip(testDb, owner, { start_date: dayOffset(-12), end_date: dayOffset(-10) }).id;
+ testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(shared, USER, owner);
+ testDb.prepare('DELETE FROM trips WHERE id = ?').run(TRIP);
+ withVisits();
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.state).toBe('ok');
+ expect(listVisits).toHaveBeenCalledTimes(1);
+ });
+
+ it('DAWARICH-SYNC-047: capabilities are re-probed after a run that was not a total failure', async () => {
+ withVisits(visit({ id: 903 }));
+
+ await svc.syncUser(USER);
+
+ expect(probeCapabilities).toHaveBeenCalledTimes(1);
+ expect(storeCapabilities).toHaveBeenCalledWith(USER, CAPABILITIES);
+ });
+
+ it('DAWARICH-SYNC-048: a failed probe does not turn a good sync into a failure', async () => {
+ withVisits(visit({ id: 904 }));
+ probeCapabilities.mockRejectedValue(new DawarichError('server_error', 'boom'));
+
+ const result = await svc.syncUser(USER);
+
+ expect(result.state).toBe('ok');
+ expect(storeCapabilities).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-049: a failed run is not followed by a probe', async () => {
+ listVisits.mockRejectedValue(new DawarichError('unreachable', 'down'));
+
+ await svc.syncUser(USER);
+
+ expect(probeCapabilities).not.toHaveBeenCalled();
+ });
+});
+
+// ── Gates ────────────────────────────────────────────────────────────────────
+
+describe('DawarichSyncService — gates', () => {
+ it('DAWARICH-SYNC-050: without the addon the run is stored as failed/addon_disabled', async () => {
+ setAddonEnabled(testDb, 'dawarich', false);
+
+ const result = await svc.syncUser(USER);
+
+ expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 });
+ expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'addon_disabled');
+ expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'addon_disabled' });
+ expect(listVisits).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-051: without a connection the run is stored as failed/not_connected', async () => {
+ connect(USER, { apiKey: null });
+
+ const result = await svc.syncUser(USER);
+
+ expect(result).toMatchObject({ state: 'failed', created: 0, updated: 0, missing: 0 });
+ expect(recordSyncResult).toHaveBeenCalledWith(USER, 'failed', 'not_connected');
+ expect(connection()).toMatchObject({ last_sync_state: 'failed', last_sync_error: 'not_connected' });
+ expect(listVisits).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-052: syncGloballyEnabled follows the addon row', () => {
+ expect(svc.syncGloballyEnabled()).toBe(true);
+ setAddonEnabled(testDb, 'dawarich', false);
+ expect(svc.syncGloballyEnabled()).toBe(false);
+ });
+});
+
+// ── runSync (the cron entry point) ───────────────────────────────────────────
+
+describe('DawarichSyncService — runSync', () => {
+ it('DAWARICH-SYNC-060: does nothing at all while the addon is off', async () => {
+ setAddonEnabled(testDb, 'dawarich', false);
+
+ await svc.runSync();
+
+ expect(listSyncableUserIds).not.toHaveBeenCalled();
+ expect(recordSyncResult).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-061: one user blowing up does not stop the next one', async () => {
+ const other = createUser(testDb).user.id;
+ createTrip(testDb, other, { start_date: TRIP_START, end_date: TRIP_END });
+ connect(other);
+ withVisits();
+ // A hard throw, i.e. the case syncUser does not catch for itself.
+ getCredentials.mockImplementationOnce(() => {
+ throw new Error('credential store exploded');
+ });
+
+ await svc.runSync();
+
+ expect(listSyncableUserIds).toHaveBeenCalledTimes(1);
+ expect(recordSyncResult).toHaveBeenCalledWith(other, 'ok', null);
+ });
+
+ it('DAWARICH-SYNC-062: skips a connection whose background sync is switched off', async () => {
+ connect(USER, { syncEnabled: false });
+ withVisits();
+
+ await svc.runSync();
+
+ expect(recordSyncResult).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-SYNC-063: a second tick while one is still running is dropped', async () => {
+ let release: (value: { visits: DawarichVisitRaw[]; truncated: boolean; version: string | null }) => void = () => {};
+ listVisits.mockReturnValue(
+ new Promise((resolve) => {
+ release = resolve;
+ }),
+ );
+
+ const first = svc.runSync();
+ const second = svc.runSync();
+ release({ visits: [], truncated: false, version: null });
+ await Promise.all([first, second]);
+
+ expect(listSyncableUserIds).toHaveBeenCalledTimes(1);
+ expect(listVisits).toHaveBeenCalledTimes(1);
+ });
+
+ it('DAWARICH-SYNC-064: the guard is released again, so the next tick runs', async () => {
+ withVisits();
+
+ await svc.runSync();
+ await svc.runSync();
+
+ expect(listSyncableUserIds).toHaveBeenCalledTimes(2);
+ });
+
+ it('DAWARICH-SYNC-065: a second "check now" while one is running is answered, not started', async () => {
+ // The module flag only guards the cron against itself; the button calls
+ // syncUser directly, so without a per-user guard a held-down button asks
+ // the same instance for the same windows several times over.
+ withVisits(visit({ id: 901 }));
+ const first = svc.syncUser(USER);
+ const second = await svc.syncUser(USER);
+
+ expect(second.alreadyRunning).toBe(true);
+ expect(second).toMatchObject({ created: 0, updated: 0, missing: 0 });
+ await first;
+ // And the guard is released, so the next press does run.
+ expect((await svc.syncUser(USER)).alreadyRunning).toBeUndefined();
+ });
+});
diff --git a/server/tests/unit/nest/dawarich.client.test.ts b/server/tests/unit/nest/dawarich.client.test.ts
new file mode 100644
index 000000000..1a54e9270
--- /dev/null
+++ b/server/tests/unit/nest/dawarich.client.test.ts
@@ -0,0 +1,540 @@
+/**
+ * DawarichClient — what it puts on the wire, and what it makes of what comes
+ * back.
+ *
+ * The client is the only place TREK talks to a user's Dawarich instance, so
+ * both halves of that contract are pinned here: the URL and headers it builds
+ * (a pasted `/api` must not become `/api/api/v1`, and the API key must never
+ * reach a query string, where every proxy log between here and there would keep
+ * it), and the error code it hands back for each way a self-hosted instance can
+ * disappoint — a wrong key, a Cloudflare 403, a reverse proxy answering with an
+ * HTML login page.
+ *
+ * `safeFetch` and `readCappedJson` are mocked: the SSRF guard does a real DNS
+ * lookup and the capped reader wants a real stream, and neither is what this
+ * file is about. The readCappedJson stub still parses the bytes the response
+ * hands it, so "Dawarich answered HTML" stays a genuine parse failure rather
+ * than a mock returning `undefined` on command.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+const { safeFetchMock, readCappedJsonMock } = vi.hoisted(() => ({
+ safeFetchMock: vi.fn(),
+ readCappedJsonMock: vi.fn(),
+}));
+vi.mock('../../../src/utils/ssrfGuard', () => ({ safeFetch: safeFetchMock }));
+vi.mock('../../../src/utils/cappedFetch', () => ({ readCappedJson: readCappedJsonMock }));
+
+import {
+ DawarichClient,
+ DawarichError,
+ isoUtc,
+ unixSeconds,
+ POINTS_PAGE_SIZE,
+ TRACKS_PAGE_SIZE,
+ VISITS_PAGE_SIZE,
+ type DawarichCreds,
+ type DawarichVisitRaw,
+} from '../../../src/nest/integrations/dawarich.client';
+
+const API_KEY = 'sk-dawarich-2f7c9';
+
+const CREDS: DawarichCreds = {
+ baseUrl: 'https://daw.example.org',
+ apiKey: API_KEY,
+ allowInsecureTls: false,
+};
+
+const FROM = new Date('2026-09-01T00:00:00.000Z');
+const TO = new Date('2026-09-08T00:00:00.000Z');
+
+/** The bits of a Response this client touches, plus the bytes the reader sees. */
+interface Reply {
+ ok: boolean;
+ status: number;
+ headers: Headers;
+ text(): Promise;
+}
+
+interface Recorded {
+ url: string;
+ init: RequestInit;
+ options?: { rejectUnauthorized?: boolean };
+}
+
+let calls: Recorded[] = [];
+
+function reply(opts: {
+ status?: number;
+ body?: unknown;
+ /** Raw bytes, for the answers that are not JSON at all. */
+ raw?: string;
+ headers?: Record;
+}): Reply {
+ const status = opts.status ?? 200;
+ const raw = opts.raw ?? JSON.stringify(opts.body ?? {});
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers(opts.headers ?? {}),
+ text: async () => raw,
+ };
+}
+
+/**
+ * Queue the answers, in order. The last one repeats, which is what the
+ * pagination cases want: twenty identical pages are one `reply`.
+ */
+function answerWith(...replies: Reply[]): void {
+ const queue = [...replies];
+ safeFetchMock.mockImplementation(
+ async (url: string, init: RequestInit, options?: { rejectUnauthorized?: boolean }) => {
+ calls.push({ url, init, options });
+ const next = queue.length > 1 ? queue.shift() : queue[0];
+ if (!next) throw new Error('the test queued no answer');
+ return next as unknown as Response;
+ },
+ );
+}
+
+/** A transport-level failure: DNS, TLS, the abort timeout. */
+function failTransport(message: string): void {
+ safeFetchMock.mockImplementation(async (url: string, init: RequestInit) => {
+ calls.push({ url, init });
+ throw new Error(message);
+ });
+}
+
+/** The DawarichError a call rejects with, or a failure saying it did not. */
+async function failure(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (err: unknown) {
+ if (err instanceof DawarichError) return err;
+ throw err;
+ }
+ throw new Error('expected the call to reject with a DawarichError');
+}
+
+function requestedUrl(index = 0): URL {
+ return new URL(calls[index].url);
+}
+
+function headerOf(index: number, name: string): string | undefined {
+ const headers = calls[index].init.headers as Record | undefined;
+ return headers?.[name];
+}
+
+let client: DawarichClient;
+
+beforeEach(() => {
+ calls = [];
+ client = new DawarichClient();
+ safeFetchMock.mockReset();
+ readCappedJsonMock.mockReset();
+ // The real reader, minus the stream: parse what arrived, answer undefined when
+ // it is not JSON — the same verdict the capped reader reaches.
+ readCappedJsonMock.mockImplementation(async (res: { text(): Promise }) => {
+ const text = await res.text();
+ try {
+ return JSON.parse(text) as unknown;
+ } catch {
+ return undefined;
+ }
+ });
+ // Nothing here may reach a real instance: an unstubbed call throws rather
+ // than silently resolving to undefined.
+ safeFetchMock.mockImplementation(async () => {
+ throw new Error('unstubbed safeFetch');
+ });
+});
+
+describe('DawarichClient — URL building', () => {
+ it('DAWARICH-CLIENT-001: appends /api/v1 to a bare origin', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe(CREDS);
+ expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me');
+ });
+
+ it('DAWARICH-CLIENT-002: tolerates a trailing slash', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/' });
+ expect(requestedUrl().href).toBe('https://daw.example.org/api/v1/users/me');
+ });
+
+ it('DAWARICH-CLIENT-003: tolerates a pasted /api instead of doubling it', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api' });
+ expect(requestedUrl().pathname).toBe('/api/v1/users/me');
+ });
+
+ it('DAWARICH-CLIENT-004: tolerates a pasted /api/v1, with or without the slash', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1' });
+ await client.probe({ ...CREDS, baseUrl: 'https://daw.example.org/api/v1/' });
+ expect(requestedUrl(0).href).toBe('https://daw.example.org/api/v1/users/me');
+ expect(requestedUrl(1).href).toBe('https://daw.example.org/api/v1/users/me');
+ });
+
+ it('DAWARICH-CLIENT-005: keeps a reverse-proxy sub-path and trims pasted whitespace', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe({ ...CREDS, baseUrl: ' https://home.example.org/dawarich/ ' });
+ expect(requestedUrl().href).toBe('https://home.example.org/dawarich/api/v1/users/me');
+ });
+
+ it('DAWARICH-CLIENT-006: keeps a non-default port', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe({ ...CREDS, baseUrl: 'http://192.168.178.9:3000/api' });
+ expect(requestedUrl().href).toBe('http://192.168.178.9:3000/api/v1/users/me');
+ });
+});
+
+describe('DawarichClient — authentication', () => {
+ it('DAWARICH-CLIENT-010: sends the key as a Bearer token and asks for JSON', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe(CREDS);
+ expect(headerOf(0, 'Authorization')).toBe(`Bearer ${API_KEY}`);
+ expect(headerOf(0, 'Accept')).toBe('application/json');
+ expect(calls[0].init.method).toBe('GET');
+ });
+
+ it('DAWARICH-CLIENT-011: never puts the key in the query string, on any endpoint', async () => {
+ answerWith(reply({ body: [] }));
+ await client.probe(CREDS);
+ await client.listVisits(CREDS, FROM, TO);
+ await client.listTracks(CREDS, FROM, TO);
+ await client.listPoints(CREDS, FROM, TO);
+ await client.listVisitedCities(CREDS, FROM, TO);
+ await client.findVisitsNear(CREDS, 54.09, 12.14, 120, 5);
+
+ expect(calls.length).toBeGreaterThanOrEqual(6);
+ for (const call of calls) {
+ const url = new URL(call.url);
+ expect(url.searchParams.get('api_key')).toBeNull();
+ expect(call.url).not.toContain(API_KEY);
+ expect(url.search).not.toMatch(/key|token|auth/i);
+ }
+ });
+
+ it('DAWARICH-CLIENT-012: maps allowInsecureTls onto rejectUnauthorized', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe(CREDS);
+ await client.probe({ ...CREDS, allowInsecureTls: true });
+ expect(calls[0].options).toEqual({ rejectUnauthorized: true });
+ expect(calls[1].options).toEqual({ rejectUnauthorized: false });
+ });
+
+ it('DAWARICH-CLIENT-013: bounds the request with an abort signal', async () => {
+ answerWith(reply({ body: { id: 1 } }));
+ await client.probe(CREDS);
+ expect(calls[0].init.signal).toBeInstanceOf(AbortSignal);
+ });
+});
+
+describe('DawarichClient — time formats', () => {
+ it('DAWARICH-CLIENT-020: isoUtc is UTC ISO-8601 without milliseconds', () => {
+ expect(isoUtc(new Date('2026-09-01T10:00:30.123Z'))).toBe('2026-09-01T10:00:30Z');
+ expect(isoUtc(new Date('2026-09-01T10:00:00.000Z'))).toBe('2026-09-01T10:00:00Z');
+ });
+
+ it('DAWARICH-CLIENT-021: unixSeconds is a bare digit string, floored', () => {
+ const value = unixSeconds(new Date('2026-09-01T10:00:30.900Z'));
+ expect(value).toMatch(/^\d+$/);
+ expect(new Date(Number(value) * 1000).toISOString()).toBe('2026-09-01T10:00:30.000Z');
+ });
+
+ it('DAWARICH-CLIENT-022: visits and tracks get ISO bounds, points and cities unix seconds', async () => {
+ answerWith(reply({ body: [] }));
+ await client.listVisits(CREDS, FROM, TO);
+ await client.listTracks(CREDS, FROM, TO);
+ await client.listPoints(CREDS, FROM, TO);
+ await client.listVisitedCities(CREDS, FROM, TO);
+
+ expect(requestedUrl(0).searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
+ expect(requestedUrl(1).searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z');
+ expect(requestedUrl(2).searchParams.get('start_at')).toBe(unixSeconds(FROM));
+ expect(requestedUrl(2).searchParams.get('start_at')).toMatch(/^\d+$/);
+ expect(requestedUrl(3).searchParams.get('end_at')).toBe(unixSeconds(TO));
+ });
+});
+
+describe('DawarichClient — error classification', () => {
+ const cases: Array<{ id: string; status: number; code: string }> = [
+ { id: 'DAWARICH-CLIENT-030', status: 401, code: 'unauthorized' },
+ { id: 'DAWARICH-CLIENT-031', status: 403, code: 'forbidden' },
+ { id: 'DAWARICH-CLIENT-032', status: 404, code: 'not_found' },
+ { id: 'DAWARICH-CLIENT-033', status: 429, code: 'rate_limited' },
+ { id: 'DAWARICH-CLIENT-034', status: 500, code: 'server_error' },
+ { id: 'DAWARICH-CLIENT-035', status: 502, code: 'server_error' },
+ ];
+
+ for (const { id, status, code } of cases) {
+ it(`${id}: HTTP ${status} is "${code}"`, async () => {
+ answerWith(reply({ status, raw: 'nope' }));
+ const err = await failure(() => client.probe(CREDS));
+ expect(err.code).toBe(code);
+ expect(err.status).toBe(status);
+ expect(err.name).toBe('DawarichError');
+ // The body of a failed answer is never parsed — it is as likely to be an
+ // HTML error page as JSON.
+ expect(readCappedJsonMock).not.toHaveBeenCalled();
+ });
+ }
+
+ it('DAWARICH-CLIENT-036: a transport failure is "unreachable" and keeps the reason as detail', async () => {
+ failTransport('getaddrinfo ENOTFOUND daw.example.org');
+ const err = await failure(() => client.probe(CREDS));
+ expect(err.code).toBe('unreachable');
+ expect(err.status).toBeUndefined();
+ expect(err.detail).toContain('ENOTFOUND');
+ });
+
+ it('DAWARICH-CLIENT-037: an HTML login page is "invalid_response", not a parse crash', async () => {
+ answerWith(reply({ raw: 'Sign in' }));
+ const err = await failure(() => client.probe(CREDS));
+ expect(err.code).toBe('invalid_response');
+ expect(err.message).toMatch(/not JSON/i);
+ });
+
+ it('DAWARICH-CLIENT-038: a body over the size cap is "invalid_response"', async () => {
+ answerWith(reply({ body: { huge: true } }));
+ readCappedJsonMock.mockResolvedValue(undefined);
+ const err = await failure(() => client.probe(CREDS));
+ expect(err.code).toBe('invalid_response');
+ // The cap is passed in, rather than the reader guessing one.
+ expect(readCappedJsonMock.mock.calls[0][1]).toBe(8 * 1024 * 1024);
+ });
+
+ it('DAWARICH-CLIENT-039: a 2xx answer is not classified as an error', async () => {
+ answerWith(reply({ status: 204, body: {} }));
+ await expect(client.probe(CREDS)).resolves.toEqual({ version: null });
+ });
+});
+
+describe('DawarichClient — probe', () => {
+ it('DAWARICH-CLIENT-040: asks /users/me and reads X-Dawarich-Version', async () => {
+ answerWith(reply({ body: { id: 7 }, headers: { 'X-Dawarich-Version': '1.14.4' } }));
+ const probe = await client.probe(CREDS);
+ expect(probe).toEqual({ version: '1.14.4' });
+ // /health skips authentication upstream, so it would prove nothing.
+ expect(requestedUrl().pathname).toBe('/api/v1/users/me');
+ expect(requestedUrl().pathname).not.toContain('health');
+ expect(requestedUrl().search).toBe('');
+ });
+
+ it('DAWARICH-CLIENT-041: an instance too old to send the banner probes as version null', async () => {
+ answerWith(reply({ body: { id: 7 } }));
+ await expect(client.probe(CREDS)).resolves.toEqual({ version: null });
+ });
+});
+
+describe('DawarichClient — listVisits', () => {
+ function visit(id: number): DawarichVisitRaw {
+ return {
+ id,
+ area_id: null,
+ started_at: '2026-09-02T08:00:00Z',
+ ended_at: '2026-09-02T09:30:00Z',
+ duration: 5400,
+ name: `Stop ${id}`,
+ status: 'confirmed',
+ confidence: 0.9,
+ confidence_band: 'high',
+ place: { latitude: '54.0879', longitude: '12.1408', id: id * 10 },
+ };
+ }
+
+ it('DAWARICH-CLIENT-050: sends the window, the page and the upstream page size', async () => {
+ answerWith(reply({ body: [visit(1)] }));
+ const out = await client.listVisits(CREDS, FROM, TO);
+
+ const url = requestedUrl();
+ expect(url.pathname).toBe('/api/v1/visits');
+ expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
+ expect(url.searchParams.get('end_at')).toBe('2026-09-08T00:00:00Z');
+ expect(url.searchParams.get('page')).toBe('1');
+ expect(url.searchParams.get('per_page')).toBe(String(VISITS_PAGE_SIZE));
+ expect(out.visits).toHaveLength(1);
+ expect(out.truncated).toBe(false);
+ });
+
+ it('DAWARICH-CLIENT-051: walks every page X-Total-Pages announces and concatenates them', async () => {
+ answerWith(
+ reply({ body: [visit(1)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '1.14.4' } }),
+ reply({ body: [visit(2)], headers: { 'X-Total-Pages': '3', 'X-Dawarich-Version': '9.9.9' } }),
+ reply({ body: [visit(3)], headers: { 'X-Total-Pages': '3' } }),
+ );
+ const out = await client.listVisits(CREDS, FROM, TO);
+
+ expect(calls).toHaveLength(3);
+ expect(out.visits.map(v => v.id)).toEqual([1, 2, 3]);
+ expect(out.truncated).toBe(false);
+ // The first page's banner is the answer; a later page cannot overwrite it.
+ expect(out.version).toBe('1.14.4');
+ expect(calls.map(c => new URL(c.url).searchParams.get('page'))).toEqual(['1', '2', '3']);
+ });
+
+ it('DAWARICH-CLIENT-052: an absent X-Total-Pages means one page', async () => {
+ answerWith(reply({ body: [visit(1)] }));
+ const out = await client.listVisits(CREDS, FROM, TO);
+ expect(calls).toHaveLength(1);
+ expect(out.visits).toHaveLength(1);
+ expect(out.version).toBeNull();
+ });
+
+ it('DAWARICH-CLIENT-053: stops at the page ceiling and says the result is truncated', async () => {
+ answerWith(reply({ body: [visit(1)], headers: { 'X-Total-Pages': '25' } }));
+ const out = await client.listVisits(CREDS, FROM, TO);
+
+ expect(calls).toHaveLength(20);
+ expect(out.visits).toHaveLength(20);
+ expect(out.truncated).toBe(true);
+ expect(new URL(calls[19].url).searchParams.get('page')).toBe('20');
+ });
+
+ it('DAWARICH-CLIENT-054: a payload that is not a list is "invalid_response"', async () => {
+ answerWith(reply({ body: { visits: [] } }));
+ const err = await failure(() => client.listVisits(CREDS, FROM, TO));
+ expect(err.code).toBe('invalid_response');
+ expect(err.message).toMatch(/not a list/i);
+ });
+});
+
+describe('DawarichClient — listTracks', () => {
+ const feature = (id: number) => ({
+ type: 'Feature',
+ geometry: { type: 'LineString', coordinates: [[12.1, 54.0], [12.2, 54.1]] },
+ properties: { id, start_at: '2026-09-02T08:00:00Z', end_at: '2026-09-02T09:00:00Z' },
+ });
+
+ it('DAWARICH-CLIENT-060: reads the GeoJSON features and asks for the smaller page size', async () => {
+ answerWith(reply({ body: { type: 'FeatureCollection', features: [feature(1), feature(2)] } }));
+ const out = await client.listTracks(CREDS, FROM, TO);
+
+ const url = requestedUrl();
+ expect(url.pathname).toBe('/api/v1/tracks');
+ expect(url.searchParams.get('per_page')).toBe(String(TRACKS_PAGE_SIZE));
+ expect(url.searchParams.get('start_at')).toBe('2026-09-01T00:00:00Z');
+ expect(out.features.map(f => f.properties?.id)).toEqual([1, 2]);
+ expect(out.truncated).toBe(false);
+ });
+
+ it('DAWARICH-CLIENT-061: an instance with no tracks yet answers empty rather than throwing', async () => {
+ answerWith(reply({ body: { type: 'FeatureCollection' } }));
+ await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false });
+
+ calls = [];
+ answerWith(reply({ body: { features: 'nope' } }));
+ await expect(client.listTracks(CREDS, FROM, TO)).resolves.toEqual({ features: [], truncated: false });
+ });
+
+ it('DAWARICH-CLIENT-062: paginates over X-Total-Pages and truncates at the ceiling', async () => {
+ answerWith(
+ reply({ body: { features: [feature(1)] }, headers: { 'X-Total-Pages': '2' } }),
+ reply({ body: { features: [feature(2)] }, headers: { 'X-Total-Pages': '2' } }),
+ );
+ const paged = await client.listTracks(CREDS, FROM, TO);
+ expect(calls).toHaveLength(2);
+ expect(paged.features).toHaveLength(2);
+ expect(paged.truncated).toBe(false);
+
+ calls = [];
+ answerWith(reply({ body: { features: [feature(9)] }, headers: { 'X-Total-Pages': '99' } }));
+ const capped = await client.listTracks(CREDS, FROM, TO);
+ expect(calls).toHaveLength(20);
+ expect(capped.truncated).toBe(true);
+ });
+});
+
+describe('DawarichClient — listPoints', () => {
+ const point = (id: number) => ({ id, latitude: '54.0879', longitude: '12.1408', timestamp: 1788000000 + id });
+
+ it('DAWARICH-CLIENT-070: asks for slim points in ascending order over a unix-second window', async () => {
+ answerWith(reply({ body: [point(1)] }));
+ const out = await client.listPoints(CREDS, FROM, TO);
+
+ const url = requestedUrl();
+ expect(url.pathname).toBe('/api/v1/points');
+ expect(url.searchParams.get('slim')).toBe('true');
+ expect(url.searchParams.get('order')).toBe('asc');
+ expect(url.searchParams.get('per_page')).toBe(String(POINTS_PAGE_SIZE));
+ expect(url.searchParams.get('start_at')).toBe(unixSeconds(FROM));
+ expect(url.searchParams.get('end_at')).toBe(unixSeconds(TO));
+ expect(out).toEqual({ points: [point(1)], truncated: false });
+ });
+
+ it('DAWARICH-CLIENT-071: paginates over X-Total-Pages and truncates at the ceiling', async () => {
+ answerWith(
+ reply({ body: [point(1)], headers: { 'X-Total-Pages': '2' } }),
+ reply({ body: [point(2)], headers: { 'X-Total-Pages': '2' } }),
+ );
+ const paged = await client.listPoints(CREDS, FROM, TO);
+ expect(calls).toHaveLength(2);
+ expect(paged.points.map(p => p.id)).toEqual([1, 2]);
+ expect(paged.truncated).toBe(false);
+
+ calls = [];
+ answerWith(reply({ body: [point(3)], headers: { 'X-Total-Pages': '40' } }));
+ const capped = await client.listPoints(CREDS, FROM, TO);
+ expect(calls).toHaveLength(20);
+ expect(capped.points).toHaveLength(20);
+ expect(capped.truncated).toBe(true);
+ });
+
+ it('DAWARICH-CLIENT-072: a payload that is not a list is "invalid_response"', async () => {
+ answerWith(reply({ body: { points: [] } }));
+ const err = await failure(() => client.listPoints(CREDS, FROM, TO));
+ expect(err.code).toBe('invalid_response');
+ expect(err.message).toMatch(/not a list/i);
+ });
+});
+
+describe('DawarichClient — listVisitedCities', () => {
+ it('DAWARICH-CLIENT-080: unwraps the data envelope over a unix-second window', async () => {
+ const countries = [{ country: 'Germany', cities: [{ city: 'Rostock', points: 42, timestamp: 1788000000 }] }];
+ answerWith(reply({ body: { data: countries } }));
+ const out = await client.listVisitedCities(CREDS, FROM, TO);
+
+ expect(requestedUrl().pathname).toBe('/api/v1/countries/visited_cities');
+ expect(requestedUrl().searchParams.get('start_at')).toBe(unixSeconds(FROM));
+ expect(out).toEqual(countries);
+ });
+
+ it('DAWARICH-CLIENT-081: an envelope without a list answers empty', async () => {
+ answerWith(reply({ body: {} }));
+ await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]);
+
+ calls = [];
+ answerWith(reply({ body: { data: { Germany: [] } } }));
+ await expect(client.listVisitedCities(CREDS, FROM, TO)).resolves.toEqual([]);
+ });
+});
+
+describe('DawarichClient — findVisitsNear', () => {
+ it('DAWARICH-CLIENT-090: pins the radius explicitly instead of letting Dawarich guess 500 m', async () => {
+ const visits = [{ timestamp: 1788000000, distance_meters: 40, points_count: 12 }];
+ answerWith(reply({ body: { locations: [{ visits }] } }));
+ const out = await client.findVisitsNear(CREDS, 54.0879, 12.1408, 119.6, 5);
+
+ const url = requestedUrl();
+ expect(url.pathname).toBe('/api/v1/locations');
+ expect(url.searchParams.get('lat')).toBe('54.0879');
+ expect(url.searchParams.get('lon')).toBe('12.1408');
+ expect(url.searchParams.get('radius_override')).toBe('120');
+ expect(url.searchParams.get('limit')).toBe('5');
+ expect(out).toEqual(visits);
+ });
+
+ it('DAWARICH-CLIENT-091: a coordinate the recordings never saw answers empty', async () => {
+ answerWith(reply({ body: { locations: [] } }));
+ await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
+
+ calls = [];
+ answerWith(reply({ body: {} }));
+ await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
+
+ calls = [];
+ answerWith(reply({ body: { locations: [{}] } }));
+ await expect(client.findVisitsNear(CREDS, 0, 0, 100, 5)).resolves.toEqual([]);
+ });
+});
diff --git a/server/tests/unit/nest/dawarich.controller.test.ts b/server/tests/unit/nest/dawarich.controller.test.ts
new file mode 100644
index 000000000..2fdecbf73
--- /dev/null
+++ b/server/tests/unit/nest/dawarich.controller.test.ts
@@ -0,0 +1,584 @@
+/**
+ * DawarichController unit tests — the controller built with `new`, every
+ * collaborator a plain mock. No Nest container, no DI, no network: the
+ * DawarichClient is never constructed here, because the only things under test
+ * are the ones the controller itself owns.
+ *
+ * Those are three:
+ *
+ * - **the parsers** (`parseId`, `parseOptionalId`, `parseState`, `parseDate`,
+ * `parseWindow`), the reason a bad path segment never reaches a query. They
+ * are module-private on purpose, so they are pinned through the handlers
+ * that use them rather than exported for a test's convenience;
+ * - **`toHttp`**, the one place a domain error becomes a status code — an
+ * `AcceptError` keeps the status the domain chose, a `DawarichError` becomes
+ * 502 except for the two codes that mean "your key is wrong", which are 400
+ * because they are the caller's to fix and not the upstream's fault;
+ * - **the delegation**, `X-Socket-Id` included, whose loss would make an
+ * accept echo straight back into the tab that performed it.
+ */
+import { describe, it, expect, vi } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import type { Request } from 'express';
+import type { DawarichErrorCode } from '@trek/shared';
+import { DawarichController } from '../../../src/nest/integrations/dawarich.controller';
+import { AcceptError } from '../../../src/nest/integrations/dawarich-suggestions.service';
+import { DawarichError } from '../../../src/nest/integrations/dawarich.client';
+import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
+import { AddonGuard } from '../../../src/nest/addons/addon.guard';
+import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator';
+import { ADDON_IDS } from '../../../src/addons';
+import type { DawarichService } from '../../../src/nest/integrations/dawarich.service';
+import type { DawarichSyncService } from '../../../src/nest/integrations/dawarich-sync.service';
+import type { DawarichSuggestionsService } from '../../../src/nest/integrations/dawarich-suggestions.service';
+import type { DawarichTracksService } from '../../../src/nest/integrations/dawarich-tracks.service';
+import type { User } from '../../../src/types';
+
+const user = { id: 7 } as User;
+
+interface Mocks {
+ dawarich?: Partial;
+ sync?: Partial;
+ suggestions?: Partial;
+ tracks?: Partial;
+}
+
+function makeController(m: Mocks = {}) {
+ return new DawarichController(
+ (m.dawarich ?? {}) as DawarichService,
+ (m.sync ?? {}) as DawarichSyncService,
+ (m.suggestions ?? {}) as DawarichSuggestionsService,
+ (m.tracks ?? {}) as DawarichTracksService,
+ );
+}
+
+/** A request stub carrying only what the controller reads off it. */
+function makeReq(headers: Record = {}, ip = '10.1.2.3'): Request {
+ return { headers, ip, socket: { remoteAddress: ip } } as unknown as Request;
+}
+
+/** A valid settings body — the two defaulted fields are required in the DTO's output type. */
+function settings(over: Partial<{ url: string; apiKey: string; allowInsecureTls: boolean; syncEnabled: boolean }> = {}) {
+ return {
+ url: 'https://dawarich.example',
+ allowInsecureTls: false,
+ syncEnabled: true,
+ ...over,
+ };
+}
+
+async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
+ try {
+ await fn();
+ } catch (err) {
+ expect(err).toBeInstanceOf(HttpException);
+ const e = err as HttpException;
+ return { status: e.getStatus(), body: e.getResponse() };
+ }
+ throw new Error('expected the handler to throw');
+}
+
+// ---------------------------------------------------------------------------
+// Route wiring
+// ---------------------------------------------------------------------------
+
+describe('DawarichController wiring', () => {
+ it('DAWARICH-CTRL-001: answers under /api/integrations/dawarich', () => {
+ expect(Reflect.getMetadata('path', DawarichController)).toBe('api/integrations/dawarich');
+ });
+
+ it('DAWARICH-CTRL-002: AddonGuard runs BEFORE JwtAuthGuard, so a disabled addon 404s an anonymous caller instead of confirming the route with a 401', () => {
+ const guards = Reflect.getMetadata('__guards__', DawarichController) as unknown[] | undefined;
+ // Array shape asserted first: expect(undefined).toContain(X) passes in this
+ // vitest version, so the plain form would stop guarding the moment the
+ // decorator disappeared.
+ expect(Array.isArray(guards)).toBe(true);
+ expect(guards).toEqual([AddonGuard, JwtAuthGuard]);
+ });
+
+ it('DAWARICH-CTRL-003: gates on the dawarich addon, with the label that leads the 404 body', () => {
+ expect(Reflect.getMetadata(REQUIRE_ADDON, DawarichController)).toEqual({
+ addonId: ADDON_IDS.DAWARICH,
+ label: 'Dawarich',
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Connection routes
+// ---------------------------------------------------------------------------
+
+describe('DawarichController connection routes', () => {
+ it('DAWARICH-CTRL-010: GET settings hands back what the service says, for the calling user only', () => {
+ const connection = { url: 'https://d.example', connected: true };
+ const getConnection = vi.fn().mockReturnValue(connection);
+ expect(makeController({ dawarich: { getConnection } }).getSettings(user)).toBe(connection);
+ expect(getConnection).toHaveBeenCalledWith(7);
+ });
+
+ it('DAWARICH-CTRL-011: PUT settings forwards url, key, tls flag, syncEnabled and the client ip', async () => {
+ const saveSettings = vi.fn().mockResolvedValue({ success: true });
+ const forget = vi.fn();
+ const c = makeController({ dawarich: { saveSettings }, tracks: { forget } });
+
+ const out = await c.putSettings(
+ user,
+ settings({ apiKey: 'k', allowInsecureTls: true, syncEnabled: false }),
+ makeReq({}, '203.0.113.9'),
+ );
+
+ expect(out).toEqual({ success: true });
+ expect(saveSettings).toHaveBeenCalledWith(7, 'https://dawarich.example', 'k', true, false, '203.0.113.9');
+ });
+
+ it('DAWARICH-CTRL-012: only an explicit false turns the background poll off', async () => {
+ const saveSettings = vi.fn().mockResolvedValue({ success: true });
+ const c = makeController({ dawarich: { saveSettings }, tracks: { forget: vi.fn() } });
+
+ await c.putSettings(user, settings({ syncEnabled: true }), makeReq());
+ expect(saveSettings.mock.calls[0][4]).toBe(true);
+ });
+
+ it('DAWARICH-CTRL-013: a rejected URL is a 400 carrying the service reason, and the cached track is left alone', async () => {
+ const saveSettings = vi.fn().mockResolvedValue({ success: false, error: 'Invalid URL' });
+ const forget = vi.fn();
+ const c = makeController({ dawarich: { saveSettings }, tracks: { forget } });
+
+ expect(await thrown(() => c.putSettings(user, settings({ url: 'nope' }), makeReq())))
+ .toEqual({ status: 400, body: { error: 'Invalid URL' } });
+ expect(forget).not.toHaveBeenCalled();
+ });
+
+ it("DAWARICH-CTRL-014: a saved connection drops the cached track, so the old instance's route is not drawn under the new one's name", async () => {
+ const forget = vi.fn();
+ const c = makeController({
+ dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true }) },
+ tracks: { forget },
+ });
+ await c.putSettings(user, settings(), makeReq());
+ expect(forget).toHaveBeenCalledWith(7);
+ });
+
+ it('DAWARICH-CTRL-015: a private-IP warning reaches the caller alongside the success', async () => {
+ const c = makeController({
+ dawarich: { saveSettings: vi.fn().mockResolvedValue({ success: true, warning: 'resolves to a private IP' }) },
+ tracks: { forget: vi.fn() },
+ });
+ expect(await c.putSettings(user, settings({ url: 'http://192.168.0.5:3000' }), makeReq()))
+ .toEqual({ success: true, warning: 'resolves to a private IP' });
+ });
+
+ it('DAWARICH-CTRL-016: DELETE settings disconnects, forgets the track and answers 200 { success: true }', () => {
+ const disconnect = vi.fn();
+ const forget = vi.fn();
+ const c = makeController({ dawarich: { disconnect }, tracks: { forget } });
+
+ expect(c.disconnect(user, makeReq({}, '198.51.100.4'))).toEqual({ success: true });
+ expect(disconnect).toHaveBeenCalledWith(7, '198.51.100.4');
+ expect(forget).toHaveBeenCalledWith(7);
+ });
+
+ it('DAWARICH-CTRL-017: POST test answers the probe verbatim — an unusable connection is a 200 the form can render, not an exception', async () => {
+ const status = { connected: false, error: 'unauthorized' };
+ const testConnection = vi.fn().mockResolvedValue(status);
+ const c = makeController({ dawarich: { testConnection } });
+
+ await expect(c.test(user, settings({ apiKey: 'wrong' }))).resolves.toBe(status);
+ expect(testConnection).toHaveBeenCalledWith(7, 'https://dawarich.example', 'wrong', false);
+ });
+
+ it('DAWARICH-CTRL-018: POST sync pulls for the caller and returns the counts', async () => {
+ const result = { state: 'ok', created: 2, updated: 1, missing: 0 };
+ const syncUser = vi.fn().mockResolvedValue(result);
+ await expect(makeController({ sync: { syncUser } }).syncNow(user)).resolves.toBe(result);
+ expect(syncUser).toHaveBeenCalledWith(7);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseOptionalId / parseState — the suggestions list filters
+// ---------------------------------------------------------------------------
+
+describe('DawarichController GET /suggestions filters', () => {
+ it('DAWARICH-CTRL-020: no filters means both are undefined', () => {
+ const list = vi.fn().mockReturnValue({ suggestions: [] });
+ makeController({ suggestions: { list } }).listSuggestions(user);
+ expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
+ });
+
+ it('DAWARICH-CTRL-021: an empty query value reads as "no filter", not as a filter on nothing', () => {
+ const list = vi.fn().mockReturnValue({ suggestions: [] });
+ makeController({ suggestions: { list } }).listSuggestions(user, '', '');
+ expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state: undefined });
+ });
+
+ it('DAWARICH-CTRL-022: a numeric tripId arrives as a number', () => {
+ const list = vi.fn().mockReturnValue({ suggestions: [] });
+ makeController({ suggestions: { list } }).listSuggestions(user, '42', 'new');
+ expect(list).toHaveBeenCalledWith(7, { tripId: 42, state: 'new' });
+ });
+
+ it('DAWARICH-CTRL-023: 400 on a tripId that merely starts with digits, and the field is named', () => {
+ const list = vi.fn();
+ return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, '12abc')).then((r) => {
+ expect(r).toEqual({ status: 400, body: { error: 'Invalid tripId' } });
+ expect(list).not.toHaveBeenCalled();
+ });
+ });
+
+ it.each(['new', 'accepted', 'dismissed'])('DAWARICH-CTRL-024: %s is a known state and passes through', (state) => {
+ const list = vi.fn().mockReturnValue({ suggestions: [] });
+ makeController({ suggestions: { list } }).listSuggestions(user, undefined, state);
+ expect(list).toHaveBeenCalledWith(7, { tripId: undefined, state });
+ });
+
+ it.each(['NEW', 'pending', 'all', "new' OR 1=1"])(
+ 'DAWARICH-CTRL-025: 400 on the unknown state %j rather than a silently unfiltered list',
+ (state) => {
+ const list = vi.fn();
+ return thrown(() => makeController({ suggestions: { list } }).listSuggestions(user, undefined, state)).then((r) => {
+ expect(r).toEqual({ status: 400, body: { error: 'state must be one of: new, accepted, dismissed' } });
+ expect(list).not.toHaveBeenCalled();
+ });
+ },
+ );
+});
+
+// ---------------------------------------------------------------------------
+// parseId — every route that takes an id in the path
+// ---------------------------------------------------------------------------
+
+describe('DawarichController parseId', () => {
+ const bad = ['12abc', 'abc', '', ' 12', '12 ', '1.5', '-3', '0', '+7', '1e3', '0x10', '9007199254740993'];
+
+ it.each(bad)('DAWARICH-CTRL-030: POST accept refuses the id %j with 400 and never reaches the service', (raw) => {
+ const accept = vi.fn();
+ return thrown(() => makeController({ suggestions: { accept } })
+ .acceptSuggestion(user, raw, { target: 'place' }, makeReq())).then((r) => {
+ expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
+ expect(accept).not.toHaveBeenCalled();
+ });
+ });
+
+ it('DAWARICH-CTRL-031: "12abc" is never read as 12 — this is exactly what parseInt would have let through', () => {
+ const setState = vi.fn();
+ return thrown(() => makeController({ suggestions: { setState } })
+ .setSuggestionState(user, '12abc', { state: 'dismissed' })).then((r) => {
+ expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
+ expect(setState).not.toHaveBeenCalled();
+ });
+ });
+
+ it('DAWARICH-CTRL-032: a plain positive integer is accepted, leading zeroes included', () => {
+ const setState = vi.fn().mockReturnValue({ id: 12 });
+ const c = makeController({ suggestions: { setState } });
+ expect(c.setSuggestionState(user, '012', { state: 'new' })).toEqual({ id: 12 });
+ expect(setState).toHaveBeenCalledWith(7, 12, 'new');
+ });
+
+ it('DAWARICH-CTRL-033: DELETE bucket-list/:itemId/visit refuses a non-numeric item id with 400', () => {
+ const clearBucketVisit = vi.fn();
+ return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '12abc')).then((r) => {
+ expect(r).toEqual({ status: 400, body: { error: 'Invalid id' } });
+ expect(clearBucketVisit).not.toHaveBeenCalled();
+ });
+ });
+
+ it('DAWARICH-CTRL-034: GET trips/:tripId/track refuses a non-numeric trip id with 400, not with the guard\'s 502', async () => {
+ const forTrip = vi.fn();
+ expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '12abc')))
+ .toEqual({ status: 400, body: { error: 'Invalid id' } });
+ expect(forTrip).not.toHaveBeenCalled();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// Suggestion state, accept, bucket list, atlas
+// ---------------------------------------------------------------------------
+
+describe('DawarichController suggestion routes', () => {
+ it('DAWARICH-CTRL-040: PUT state returns the updated suggestion', () => {
+ const updated = { id: 3, state: 'dismissed' };
+ const setState = vi.fn().mockReturnValue(updated);
+ expect(makeController({ suggestions: { setState } }).setSuggestionState(user, '3', { state: 'dismissed' })).toBe(updated);
+ expect(setState).toHaveBeenCalledWith(7, 3, 'dismissed');
+ });
+
+ it("DAWARICH-CTRL-041: PUT state on somebody else's suggestion is the same 404 a missing one gets — no enumeration", () => {
+ const setState = vi.fn().mockReturnValue(null);
+ return thrown(() => makeController({ suggestions: { setState } }).setSuggestionState(user, '999', { state: 'new' })).then((r) =>
+ expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found' } }));
+ });
+
+ it('DAWARICH-CTRL-042: POST accept forwards X-Socket-Id, so the broadcast does not echo into the originating tab', () => {
+ const accept = vi.fn().mockReturnValue({ createdPlaceId: 5 });
+ const body = { target: 'place' as const, tripId: 2 };
+ makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', body, makeReq({ 'x-socket-id': 'sock-abc' }));
+ expect(accept).toHaveBeenCalledWith(7, 9, body, 'sock-abc');
+ });
+
+ it('DAWARICH-CTRL-043: no X-Socket-Id means undefined, not an empty string a broadcaster would compare against', () => {
+ const accept = vi.fn().mockReturnValue({});
+ makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq());
+ expect(accept.mock.calls[0][3]).toBeUndefined();
+ });
+
+ it('DAWARICH-CTRL-044: a blank X-Socket-Id counts as absent', () => {
+ const accept = vi.fn().mockReturnValue({});
+ makeController({ suggestions: { accept } }).acceptSuggestion(user, '9', { target: 'journal' }, makeReq({ 'x-socket-id': '' }));
+ expect(accept.mock.calls[0][3]).toBeUndefined();
+ });
+
+ it('DAWARICH-CTRL-045: POST bucket-list/confirm wraps the count', () => {
+ const confirmBucketVisits = vi.fn().mockReturnValue(3);
+ expect(makeController({ suggestions: { confirmBucketVisits } })
+ .confirmBucketVisits(user, { itemIds: [1, 2, 3], visitedAt: '2026-05-01' })).toEqual({ updated: 3 });
+ expect(confirmBucketVisits).toHaveBeenCalledWith(7, [1, 2, 3], '2026-05-01');
+ });
+
+ it('DAWARICH-CTRL-046: DELETE bucket-list/:itemId/visit is a 404 when nothing was cleared', () => {
+ const clearBucketVisit = vi.fn().mockReturnValue(false);
+ return thrown(() => makeController({ suggestions: { clearBucketVisit } }).clearBucketVisit(user, '4')).then((r) =>
+ expect(r).toEqual({ status: 404, body: { error: 'Bucket-list entry not found' } }));
+ });
+
+ it('DAWARICH-CTRL-047: POST atlas/accept wraps the marked count', () => {
+ const acceptAtlasCountries = vi.fn().mockReturnValue(2);
+ expect(makeController({ suggestions: { acceptAtlasCountries } })
+ .acceptAtlasCountries(user, { countryCodes: ['DE', 'FR'] })).toEqual({ marked: 2 });
+ expect(acceptAtlasCountries).toHaveBeenCalledWith(7, ['DE', 'FR']);
+ });
+
+ it('DAWARICH-CTRL-048: POST bucket-list/scan returns the scan untouched', async () => {
+ const scan = { items: [], connected: true };
+ const scanBucketList = vi.fn().mockResolvedValue(scan);
+ await expect(makeController({ suggestions: { scanBucketList } }).scanBucketList(user)).resolves.toBe(scan);
+ expect(scanBucketList).toHaveBeenCalledWith(7);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// toHttp — the one place a domain error becomes a status
+// ---------------------------------------------------------------------------
+
+describe('DawarichController error shaping', () => {
+ it('DAWARICH-CTRL-050: an AcceptError keeps the 404 the domain chose', () => {
+ const accept = vi.fn(() => { throw new AcceptError('not_found', 'Suggestion not found', 404); });
+ return thrown(() => makeController({ suggestions: { accept } })
+ .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
+ expect(r).toEqual({ status: 404, body: { error: 'Suggestion not found', code: 'not_found' } }));
+ });
+
+ it('DAWARICH-CTRL-051: an AcceptError keeps a 409 too — the status travels with the error, it is not re-derived here', () => {
+ const accept = vi.fn(() => { throw new AcceptError('already_accepted', 'Suggestion has already been accepted', 409); });
+ return thrown(() => makeController({ suggestions: { accept } })
+ .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
+ expect(r).toEqual({ status: 409, body: { error: 'Suggestion has already been accepted', code: 'already_accepted' } }));
+ });
+
+ it('DAWARICH-CTRL-052: an AcceptError keeps a 400 (a day that is not on the trip)', () => {
+ const accept = vi.fn(() => { throw new AcceptError('day_not_on_trip', 'Day does not belong to this trip', 400); });
+ return thrown(() => makeController({ suggestions: { accept } })
+ .acceptSuggestion(user, '9', { target: 'place', dayId: 4 }, makeReq())).then((r) =>
+ expect(r).toEqual({ status: 400, body: { error: 'Day does not belong to this trip', code: 'day_not_on_trip' } }));
+ });
+
+ const upstreamCodes: DawarichErrorCode[] = [
+ 'unreachable',
+ 'not_found',
+ 'rate_limited',
+ 'server_error',
+ 'invalid_response',
+ 'too_large',
+ 'not_connected',
+ ];
+
+ it.each(upstreamCodes)(
+ 'DAWARICH-CTRL-053: a DawarichError with code %s becomes a 502 — the failure is a server TREK called, not TREK itself',
+ async (code) => {
+ const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'upstream said no'));
+ expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
+ .toEqual({ status: 502, body: { error: 'upstream said no', code } });
+ },
+ );
+
+ const callerCodes: DawarichErrorCode[] = ['unauthorized', 'forbidden'];
+
+ it.each(callerCodes)(
+ "DAWARICH-CTRL-054: a DawarichError with code %s becomes a 400 — a rejected key is the caller's to fix, not a gateway fault",
+ async (code) => {
+ const scanBucketList = vi.fn().mockRejectedValue(new DawarichError(code, 'key rejected', 401));
+ expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
+ .toEqual({ status: 400, body: { error: 'key rejected', code } });
+ },
+ );
+
+ it('DAWARICH-CTRL-055: the code reaches the client for i18n, and `detail` rides along only when there is one', async () => {
+ const scanBucketList = vi.fn().mockRejectedValue(
+ new DawarichError('invalid_response', 'Unexpected response', 200, 'login'),
+ );
+ expect(await thrown(() => makeController({ suggestions: { scanBucketList } }).scanBucketList(user)))
+ .toEqual({ status: 502, body: { error: 'Unexpected response', code: 'invalid_response', detail: 'login' } });
+ });
+
+ it('DAWARICH-CTRL-056: an HttpException raised inside the guarded call passes through untouched', async () => {
+ const forTrip = vi.fn().mockRejectedValue(new HttpException({ error: 'Trip not found' }, 404));
+ expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '3')))
+ .toEqual({ status: 404, body: { error: 'Trip not found' } });
+ });
+
+ it('DAWARICH-CTRL-057: anything else becomes a generic 502 rather than leaking the message', async () => {
+ const atlasSuggestions = vi.fn().mockRejectedValue(new Error('ECONNRESET on /api/v1/visits?api_key=secret'));
+ expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } })
+ .atlasSuggestions(user, '2026-01-01T00:00:00Z', '2026-01-05T00:00:00Z')))
+ .toEqual({ status: 502, body: { error: 'Dawarich request failed', code: 'server_error' } });
+ });
+
+ it('DAWARICH-CTRL-058: the synchronous guard shapes a throw from accept exactly as the async one does', () => {
+ const accept = vi.fn(() => { throw new DawarichError('unauthorized', 'key rejected'); });
+ return thrown(() => makeController({ suggestions: { accept } })
+ .acceptSuggestion(user, '9', { target: 'place' }, makeReq())).then((r) =>
+ expect(r).toEqual({ status: 400, body: { error: 'key rejected', code: 'unauthorized' } }));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseWindow — the explicit instant range
+// ---------------------------------------------------------------------------
+
+describe('DawarichController parseWindow', () => {
+ const halfOpen: Array<[string | undefined, string | undefined]> = [
+ [undefined, undefined],
+ ['2026-01-01T00:00:00Z', undefined],
+ [undefined, '2026-01-05T00:00:00Z'],
+ ['', '2026-01-05T00:00:00Z'],
+ ['2026-01-01T00:00:00Z', ''],
+ ['nonsense', '2026-01-05T00:00:00Z'],
+ ['2026-01-01T00:00:00Z', 'nonsense'],
+ ];
+
+ it.each(halfOpen)(
+ 'DAWARICH-CTRL-060: GET atlas/suggestions refuses the window (%j, %j) with 400 — one open end is "everything since the epoch"',
+ async (from, to) => {
+ const atlasSuggestions = vi.fn();
+ expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from, to)))
+ .toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } });
+ expect(atlasSuggestions).not.toHaveBeenCalled();
+ },
+ );
+
+ it.each(halfOpen)('DAWARICH-CTRL-061: GET track refuses the window (%j, %j) with 400', async (from, to) => {
+ const forWindow = vi.fn();
+ expect(await thrown(() => makeController({ tracks: { forWindow } }).windowTrack(user, from, to)))
+ .toEqual({ status: 400, body: { error: 'from and to are required ISO timestamps' } });
+ expect(forWindow).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-CTRL-062: an inverted window is a 400', async () => {
+ expect(await thrown(() => makeController({ suggestions: { atlasSuggestions: vi.fn() } })
+ .atlasSuggestions(user, '2026-02-01T00:00:00Z', '2026-01-01T00:00:00Z')))
+ .toEqual({ status: 400, body: { error: 'to must be after from' } });
+ });
+
+ it('DAWARICH-CTRL-063: a zero-length window is a 400 — "after from", not "at or after"', async () => {
+ expect(await thrown(() => makeController({ tracks: { forWindow: vi.fn() } })
+ .windowTrack(user, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')))
+ .toEqual({ status: 400, body: { error: 'to must be after from' } });
+ });
+
+ it('DAWARICH-CTRL-064: exactly 400 days is still allowed', async () => {
+ const atlasSuggestions = vi.fn().mockResolvedValue({ countries: [] });
+ const from = new Date('2026-01-01T00:00:00Z');
+ const to = new Date(from.getTime() + 400 * 86_400_000);
+
+ await makeController({ suggestions: { atlasSuggestions } }).atlasSuggestions(user, from.toISOString(), to.toISOString());
+
+ expect(atlasSuggestions).toHaveBeenCalledWith(7, from, to);
+ });
+
+ it('DAWARICH-CTRL-065: one millisecond past 400 days is a 400, so nobody can ask an instance to time out', async () => {
+ const atlasSuggestions = vi.fn();
+ const from = new Date('2026-01-01T00:00:00Z');
+ const to = new Date(from.getTime() + 400 * 86_400_000 + 1);
+
+ expect(await thrown(() => makeController({ suggestions: { atlasSuggestions } })
+ .atlasSuggestions(user, from.toISOString(), to.toISOString())))
+ .toEqual({ status: 400, body: { error: 'Window must be 400 days or less' } });
+ expect(atlasSuggestions).not.toHaveBeenCalled();
+ });
+
+ it('DAWARICH-CTRL-066: GET track normalises both ends to ISO before handing them on', async () => {
+ const forWindow = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forWindow } }).windowTrack(user, '2026-01-01T00:00:00+02:00', '2026-01-02T00:00:00+02:00');
+
+ // The trailing 0 is the caller's UTC offset: absent means UTC, which is
+ // what this endpoint grouped by before it asked.
+ expect(forWindow).toHaveBeenCalledWith(7, '2025-12-31T22:00:00.000Z', '2026-01-01T22:00:00.000Z', 0);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// parseDate — the trip-track narrowing
+// ---------------------------------------------------------------------------
+
+describe('DawarichController GET /trips/:tripId/track', () => {
+ it('DAWARICH-CTRL-070: no narrowing means the service decides the window from the trip', async () => {
+ const forTrip = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forTrip } }).tripTrack(user, '5');
+ expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0);
+ });
+
+ it('DAWARICH-CTRL-071: an empty from/to is "no narrowing", not an empty date', async () => {
+ const forTrip = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '', '');
+ expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 0);
+ });
+
+ it('DAWARICH-CTRL-072: YYYY-MM-DD passes through', async () => {
+ const forTrip = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forTrip } }).tripTrack(user, '5', '2026-05-01', '2026-05-03');
+ expect(forTrip).toHaveBeenCalledWith(7, 5, '2026-05-01', '2026-05-03', 0);
+ });
+
+ it('DAWARICH-CTRL-074: the offset from the caller decides which day a point belongs to', async () => {
+ const forTrip = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, '120');
+ expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, 120);
+ });
+
+ it.each([
+ ['9999', 840],
+ ['-9999', -720],
+ ['not-a-number', 0],
+ ['', 0],
+ ['-210.9', -210],
+ ])('DAWARICH-CTRL-075: the offset %j is bounded to the real world as %i', async (raw, expected) => {
+ // A junk offset must not be able to shift a day by a week; the bounds are
+ // the actual range of world offsets, -12h to +14h.
+ const forTrip = vi.fn().mockResolvedValue({ days: [] });
+ await makeController({ tracks: { forTrip } }).tripTrack(user, '5', undefined, undefined, raw);
+ expect(forTrip).toHaveBeenCalledWith(7, 5, undefined, undefined, expected);
+ });
+
+ it.each(['2026-5-1', '01.05.2026', '2026-05-01T00:00:00Z', 'yesterday'])(
+ 'DAWARICH-CTRL-073: 400 on the non-ISO date %j',
+ async (raw) => {
+ const forTrip = vi.fn();
+ expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5', raw)))
+ .toEqual({ status: 400, body: { error: 'Dates must be ISO, YYYY-MM-DD' } });
+ expect(forTrip).not.toHaveBeenCalled();
+ },
+ );
+
+ it('DAWARICH-CTRL-074: a trip the caller cannot read is a 404 — the service says null, the controller says not found', async () => {
+ const forTrip = vi.fn().mockResolvedValue(null);
+ expect(await thrown(() => makeController({ tracks: { forTrip } }).tripTrack(user, '5')))
+ .toEqual({ status: 404, body: { error: 'Trip not found' } });
+ });
+
+ it('DAWARICH-CTRL-075: a track comes back untouched', async () => {
+ const track = { days: [{ date: '2026-05-01', segments: [] }], truncated: false };
+ const forTrip = vi.fn().mockResolvedValue(track);
+ await expect(makeController({ tracks: { forTrip } }).tripTrack(user, '5')).resolves.toBe(track);
+ });
+});
diff --git a/server/tests/unit/nest/dawarich.helpers.test.ts b/server/tests/unit/nest/dawarich.helpers.test.ts
new file mode 100644
index 000000000..d6c0a1d13
--- /dev/null
+++ b/server/tests/unit/nest/dawarich.helpers.test.ts
@@ -0,0 +1,858 @@
+/**
+ * The pure half of the Dawarich integration — DAWARICH-TIME-001 through
+ * DAWARICH-COUNT-003. Everything under test is a free function over plain
+ * fixtures: no DB, no container, and no client. `dawarich.client` appears here
+ * only as a type-only import (erased at compile time), so nothing in this file
+ * can reach the network even by accident — there is no DawarichClient instance
+ * to mock because the helpers never construct one.
+ *
+ * Every timestamp is written with an explicit offset (`Z` or `+HH:MM`) so the
+ * cases mean the same thing on a CI runner in UTC and on a laptop in Berlin.
+ */
+import { describe, it, expect } from 'vitest';
+
+import {
+ bucketPointsByDay,
+ bucketTracksByDay,
+ capPoints,
+ countPoints,
+ distanceMeters,
+ geoJsonToLatLng,
+ localDateOf,
+ minutesBetween,
+ normalizeVisit,
+ offsetMinutesOf,
+ simplify,
+ syncWindow,
+ toNumber,
+ visitHash,
+} from '../../../src/nest/integrations/dawarich.helpers';
+import type {
+ DawarichSlimPoint,
+ DawarichTrackFeature,
+ DawarichVisitRaw,
+} from '../../../src/nest/integrations/dawarich.client';
+import type { DawarichTrackDay } from '@trek/shared';
+
+// ── Fixtures ─────────────────────────────────────────────────────────────────
+
+const BASE_VISIT: DawarichVisitRaw = {
+ id: 42,
+ area_id: null,
+ started_at: '2024-06-15T18:30:00+02:00',
+ ended_at: '2024-06-15T20:00:00+02:00',
+ duration: 5400,
+ name: ' Cafe Kranzler ',
+ status: 'confirmed',
+ confidence: '0.87',
+ confidence_band: 'high',
+ place: { latitude: '52.5', longitude: '13.4', id: 7, country_code: 'de' },
+};
+
+const visit = (over: Partial = {}): DawarichVisitRaw => ({ ...BASE_VISIT, ...over });
+
+/** A fixture whose shape is deliberately wrong — the payload is a stranger's JSON. */
+const malformedVisit = (over: Record): DawarichVisitRaw =>
+ ({ ...BASE_VISIT, ...over }) as unknown as DawarichVisitRaw;
+
+const point = (iso: string, lat: number | string, lng: number | string): DawarichSlimPoint => ({
+ latitude: lat,
+ longitude: lng,
+ timestamp: Date.parse(iso) / 1000,
+});
+
+const track = (
+ startAt: string | undefined,
+ endAt: string | undefined,
+ coordinates: unknown,
+ props: { distance?: number | null; dominant_mode?: string | null } = {},
+): DawarichTrackFeature => ({
+ geometry: { coordinates },
+ properties: { start_at: startAt, end_at: endAt, distance: null, dominant_mode: null, ...props },
+});
+
+// ── localDateOf / offsetMinutesOf ────────────────────────────────────────────
+
+describe('localDateOf / offsetMinutesOf', () => {
+ it('DAWARICH-TIME-001: reads the local date off the string rather than converting it', () => {
+ // 23:30 in Tokyo is still the 15th there and the 15th in Dawarich, even
+ // though the same instant is the 15th 14:30 UTC.
+ expect(localDateOf('2024-06-15T23:30:00+09:00')).toBe('2024-06-15');
+ });
+
+ it('DAWARICH-TIME-002: takes a UTC instant as given — there is no local meaning to recover', () => {
+ expect(localDateOf('2024-06-15T23:30:00Z')).toBe('2024-06-15');
+ });
+
+ it('DAWARICH-TIME-003: reads a positive offset as minutes', () => {
+ expect(offsetMinutesOf('2024-06-15T10:00:00+02:00')).toBe(120);
+ });
+
+ it('DAWARICH-TIME-004: reads a negative half-hour offset as minutes', () => {
+ expect(offsetMinutesOf('2024-06-15T10:00:00-05:30')).toBe(-330);
+ });
+
+ it('DAWARICH-TIME-005: accepts the colon-less form', () => {
+ expect(offsetMinutesOf('2024-06-15T10:00:00+0200')).toBe(120);
+ });
+
+ it('DAWARICH-TIME-006: Z, a bare date and junk are all zero', () => {
+ expect(offsetMinutesOf('2024-06-15T10:00:00Z')).toBe(0);
+ // A bare date ends in `-15`, which must not be mistaken for an offset.
+ expect(offsetMinutesOf('2024-06-15')).toBe(0);
+ expect(offsetMinutesOf('not a timestamp')).toBe(0);
+ });
+});
+
+// ── minutesBetween ───────────────────────────────────────────────────────────
+
+describe('minutesBetween', () => {
+ it('DAWARICH-TIME-007: whole minutes across a span', () => {
+ expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T11:30:00Z')).toBe(90);
+ });
+
+ it('DAWARICH-TIME-008: rounds to the nearest minute in both directions', () => {
+ expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:29Z')).toBe(0);
+ expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:31Z')).toBe(1);
+ });
+
+ it('DAWARICH-TIME-009: compares instants, not wall clocks', () => {
+ // 10:00+02:00 is 08:00Z; 11:00+01:00 is 10:00Z — two hours apart.
+ expect(minutesBetween('2024-06-15T10:00:00+02:00', '2024-06-15T11:00:00+01:00')).toBe(120);
+ });
+
+ it('DAWARICH-TIME-010: a backwards or zero-length span is zero, never negative', () => {
+ expect(minutesBetween('2024-06-15T11:00:00Z', '2024-06-15T10:00:00Z')).toBe(0);
+ expect(minutesBetween('2024-06-15T10:00:00Z', '2024-06-15T10:00:00Z')).toBe(0);
+ });
+
+ it('DAWARICH-TIME-011: an unparsable timestamp is zero rather than NaN', () => {
+ expect(minutesBetween('2024-06-15T10:00:00Z', 'nope')).toBe(0);
+ expect(minutesBetween('nope', '2024-06-15T10:00:00Z')).toBe(0);
+ });
+});
+
+// ── syncWindow ───────────────────────────────────────────────────────────────
+
+describe('syncWindow', () => {
+ it('DAWARICH-WINDOW-001: widens backwards by the lookback and forwards by the lookahead', () => {
+ const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 2, 3);
+ expect(window?.from.toISOString()).toBe('2024-06-13T00:00:00.000Z');
+ expect(window?.to.toISOString()).toBe('2024-06-20T23:59:59.000Z');
+ });
+
+ it('DAWARICH-WINDOW-002: a zero lookback/lookahead still spans the trip itself', () => {
+ const window = syncWindow('2024-06-15', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 0, 0);
+ expect(window?.from.toISOString()).toBe('2024-06-15T00:00:00.000Z');
+ expect(window?.to.toISOString()).toBe('2024-06-17T23:59:59.000Z');
+ });
+
+ it('DAWARICH-WINDOW-003: never asks past now — the lookahead is clamped', () => {
+ const now = new Date('2024-06-16T08:00:00Z');
+ const window = syncWindow('2024-06-15', '2024-06-17', now, 1, 5);
+ expect(window?.to.toISOString()).toBe(now.toISOString());
+ });
+
+ it('DAWARICH-WINDOW-004: an open-ended trip gets the lookback and stops at now', () => {
+ const now = new Date('2024-06-20T12:00:00Z');
+ const window = syncWindow('2024-06-15', null, now, 1, 7);
+ expect(window?.from.toISOString()).toBe('2024-06-14T00:00:00.000Z');
+ expect(window?.to.toISOString()).toBe(now.toISOString());
+ });
+
+ it('DAWARICH-WINDOW-005: an unparsable end date is treated as open-ended, not as a failure', () => {
+ const now = new Date('2024-06-20T12:00:00Z');
+ const window = syncWindow('2024-06-15', 'whenever', now, 1, 7);
+ expect(window?.to.toISOString()).toBe(now.toISOString());
+ });
+
+ it('DAWARICH-WINDOW-006: a missing start date yields no window at all', () => {
+ expect(syncWindow(null, '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
+ });
+
+ it('DAWARICH-WINDOW-007: an unparsable or empty start date yields no window at all', () => {
+ expect(syncWindow('gibberish', '2024-06-17', new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
+ expect(syncWindow('', null, new Date('2026-01-01T00:00:00Z'), 1, 1)).toBeNull();
+ });
+
+ it('DAWARICH-WINDOW-008: a trip that has not started yet collapses to nothing', () => {
+ // `now` is before the (already widened) start, so there is no recorded
+ // stretch to ask about: to <= from.
+ expect(syncWindow('2024-06-15', null, new Date('2024-06-10T00:00:00Z'), 1, 1)).toBeNull();
+ });
+});
+
+// ── visitHash ────────────────────────────────────────────────────────────────
+
+describe('visitHash', () => {
+ it('DAWARICH-HASH-001: is a sha256 hex digest', () => {
+ expect(visitHash(BASE_VISIT)).toMatch(/^[0-9a-f]{64}$/);
+ });
+
+ it('DAWARICH-HASH-002: is stable for the same input — a re-run that changed nothing stays quiet', () => {
+ expect(visitHash(visit())).toBe(visitHash(visit()));
+ });
+
+ it('DAWARICH-HASH-003: changes when the visit is renamed', () => {
+ expect(visitHash(visit({ name: 'Cafe Einstein' }))).not.toBe(visitHash(BASE_VISIT));
+ });
+
+ it('DAWARICH-HASH-004: changes when either timestamp moves', () => {
+ expect(visitHash(visit({ started_at: '2024-06-15T18:45:00+02:00' }))).not.toBe(visitHash(BASE_VISIT));
+ expect(visitHash(visit({ ended_at: '2024-06-15T21:00:00+02:00' }))).not.toBe(visitHash(BASE_VISIT));
+ });
+
+ it('DAWARICH-HASH-005: changes when the place moves or is re-identified', () => {
+ const moved = visit({ place: { ...BASE_VISIT.place!, latitude: '52.6' } });
+ const shifted = visit({ place: { ...BASE_VISIT.place!, longitude: '13.5' } });
+ const reidentified = visit({ place: { ...BASE_VISIT.place!, id: 8 } });
+ expect(visitHash(moved)).not.toBe(visitHash(BASE_VISIT));
+ expect(visitHash(shifted)).not.toBe(visitHash(BASE_VISIT));
+ expect(visitHash(reidentified)).not.toBe(visitHash(BASE_VISIT));
+ });
+
+ it('DAWARICH-HASH-006: changes when the status changes', () => {
+ expect(visitHash(visit({ status: 'suggested' }))).not.toBe(visitHash(BASE_VISIT));
+ });
+
+ it('DAWARICH-HASH-007: ignores fields TREK does not show — a re-scored confidence is not a change', () => {
+ expect(visitHash(visit({ confidence: '0.11', confidence_band: 'low' }))).toBe(visitHash(BASE_VISIT));
+ expect(visitHash(visit({ area_id: 99, user_id: 3 }))).toBe(visitHash(BASE_VISIT));
+ });
+
+ it('DAWARICH-HASH-008: a missing name and an empty name are the same visit', () => {
+ expect(visitHash(visit({ name: null }))).toBe(visitHash(visit({ name: '' })));
+ });
+
+ it('DAWARICH-HASH-009: a missing place does not throw', () => {
+ expect(visitHash(visit({ place: null }))).toMatch(/^[0-9a-f]{64}$/);
+ });
+});
+
+// ── toNumber ─────────────────────────────────────────────────────────────────
+
+describe('toNumber', () => {
+ it('DAWARICH-NUM-001: passes finite numbers through and rejects the rest', () => {
+ expect(toNumber(52.5)).toBe(52.5);
+ expect(toNumber(0)).toBe(0);
+ expect(toNumber(Number.NaN)).toBeNull();
+ expect(toNumber(Number.POSITIVE_INFINITY)).toBeNull();
+ });
+
+ it('DAWARICH-NUM-002: parses numeric strings, rejecting blank and non-numeric ones', () => {
+ expect(toNumber('13.4')).toBe(13.4);
+ expect(toNumber(' ')).toBeNull();
+ expect(toNumber('north')).toBeNull();
+ });
+
+ it('DAWARICH-NUM-003: null, undefined and objects are not numbers', () => {
+ expect(toNumber(null)).toBeNull();
+ expect(toNumber(undefined)).toBeNull();
+ expect(toNumber({})).toBeNull();
+ });
+});
+
+// ── normalizeVisit ───────────────────────────────────────────────────────────
+
+describe('normalizeVisit', () => {
+ it('DAWARICH-VISIT-001: reduces a full visit to what TREK stores', () => {
+ expect(normalizeVisit(BASE_VISIT)).toEqual({
+ sourceVisitId: '42',
+ name: 'Cafe Kranzler',
+ lat: 52.5,
+ lng: 13.4,
+ startedAt: '2024-06-15T18:30:00+02:00',
+ endedAt: '2024-06-15T20:00:00+02:00',
+ durationMinutes: 90,
+ localDate: '2024-06-15',
+ status: 'confirmed',
+ confidence: 0.87,
+ confidenceBand: 'high',
+ countryCodeFromSource: 'DE',
+ });
+ });
+
+ it('DAWARICH-VISIT-002: an empty visit keeps every optional field null instead of guessing', () => {
+ const sparse = visit({
+ id: '9',
+ started_at: '2024-06-15T18:30:00Z',
+ ended_at: '2024-06-15T19:15:00Z',
+ duration: null,
+ name: null,
+ status: null,
+ confidence: null,
+ confidence_band: null,
+ place: null,
+ });
+ expect(normalizeVisit(sparse)).toEqual({
+ sourceVisitId: '9',
+ name: 'Unnamed stay',
+ lat: null,
+ lng: null,
+ startedAt: '2024-06-15T18:30:00Z',
+ endedAt: '2024-06-15T19:15:00Z',
+ durationMinutes: 45,
+ localDate: '2024-06-15',
+ status: 'suggested',
+ confidence: null,
+ confidenceBand: null,
+ countryCodeFromSource: null,
+ });
+ });
+
+ it('DAWARICH-VISIT-003: a whitespace-only name falls back to the placeholder', () => {
+ expect(normalizeVisit(visit({ name: ' ' }))?.name).toBe('Unnamed stay');
+ });
+
+ it('DAWARICH-VISIT-004: a place without coordinates yields null lat/lng, not zeros', () => {
+ const result = normalizeVisit(visit({ place: { latitude: null, longitude: null, id: null } }));
+ expect(result?.lat).toBeNull();
+ expect(result?.lng).toBeNull();
+ });
+
+ it('DAWARICH-VISIT-005: "confirmed" is the only status taken at face value', () => {
+ expect(normalizeVisit(visit({ status: 'confirmed' }))?.status).toBe('confirmed');
+ expect(normalizeVisit(visit({ status: 'suggested' }))?.status).toBe('suggested');
+ });
+
+ it('DAWARICH-VISIT-006: an unknown status falls to "suggested" — the side that asks, not the side that acts', () => {
+ // "declined" stopped existing in Dawarich 1.12.0; a future value must not
+ // silently become a confirmation.
+ expect(normalizeVisit(visit({ status: 'declined' }))?.status).toBe('suggested');
+ expect(normalizeVisit(visit({ status: 'something_new' }))?.status).toBe('suggested');
+ expect(normalizeVisit(visit({ status: null }))?.status).toBe('suggested');
+ });
+
+ it('DAWARICH-VISIT-007: a positive duration is read as seconds', () => {
+ expect(normalizeVisit(visit({ duration: 5400 }))?.durationMinutes).toBe(90);
+ expect(normalizeVisit(visit({ duration: '120' }))?.durationMinutes).toBe(2);
+ });
+
+ it('DAWARICH-VISIT-008: a missing or zero duration is recomputed from the two timestamps', () => {
+ // 18:30 → 20:00 is 90 minutes whichever way it is derived.
+ expect(normalizeVisit(visit({ duration: null }))?.durationMinutes).toBe(90);
+ expect(normalizeVisit(visit({ duration: 0 }))?.durationMinutes).toBe(90);
+ expect(normalizeVisit(visit({ duration: -60 }))?.durationMinutes).toBe(90);
+ expect(normalizeVisit(visit({ duration: 'unknown' }))?.durationMinutes).toBe(90);
+ });
+
+ it('DAWARICH-VISIT-009: a two-letter country code from the source is upper-cased', () => {
+ expect(normalizeVisit(visit())?.countryCodeFromSource).toBe('DE');
+ const upper = visit({ place: { ...BASE_VISIT.place!, country_code: 'FR' } });
+ expect(normalizeVisit(upper)?.countryCodeFromSource).toBe('FR');
+ });
+
+ it('DAWARICH-VISIT-010: anything that is not a two-letter code is dropped', () => {
+ const three = visit({ place: { ...BASE_VISIT.place!, country_code: 'deu' } });
+ const empty = visit({ place: { ...BASE_VISIT.place!, country_code: '' } });
+ const absent = visit({ place: { latitude: '52.5', longitude: '13.4', id: 7 } });
+ expect(normalizeVisit(three)?.countryCodeFromSource).toBeNull();
+ expect(normalizeVisit(empty)?.countryCodeFromSource).toBeNull();
+ expect(normalizeVisit(absent)?.countryCodeFromSource).toBeNull();
+ });
+
+ it('DAWARICH-VISIT-011: the local date comes from the source offset, not from the host clock', () => {
+ const tokyoLateNight = visit({
+ started_at: '2024-06-15T23:30:00+09:00',
+ ended_at: '2024-06-16T00:30:00+09:00',
+ });
+ expect(normalizeVisit(tokyoLateNight)?.localDate).toBe('2024-06-15');
+ });
+
+ it('DAWARICH-VISIT-012: a visit without an id is unusable', () => {
+ expect(normalizeVisit(malformedVisit({ id: null }))).toBeNull();
+ expect(normalizeVisit(malformedVisit({ id: undefined }))).toBeNull();
+ });
+
+ it('DAWARICH-VISIT-013: non-string timestamps are unusable', () => {
+ expect(normalizeVisit(malformedVisit({ started_at: 1_718_476_200 }))).toBeNull();
+ expect(normalizeVisit(malformedVisit({ ended_at: null }))).toBeNull();
+ });
+
+ it('DAWARICH-VISIT-014: an unparsable start timestamp is unusable', () => {
+ expect(normalizeVisit(visit({ started_at: 'last Tuesday' }))).toBeNull();
+ });
+
+ it('DAWARICH-VISIT-015: an unreadable end collapses onto the arrival rather than staying unreadable', () => {
+ // The guard checks `started_at` alone, so an unreadable end does not drop
+ // the visit. It is repaired instead of passed through: kept as-is it would
+ // render as "Invalid Date" beside a perfectly good arrival time.
+ const result = normalizeVisit(visit({ duration: null, ended_at: 'sometime' }));
+ expect(result?.endedAt).toBe(new Date(Date.parse(result!.startedAt)).toISOString());
+ expect(result?.durationMinutes).toBe(0);
+ });
+
+ it('DAWARICH-VISIT-016: an unreadable end falls back to the reported duration', () => {
+ // With a duration to go on, the end is the arrival plus that span — which is
+ // what the stay actually was, and what the two timestamps then agree on.
+ const result = normalizeVisit(visit({ duration: 1800, ended_at: 'sometime' }));
+ expect(Date.parse(result!.endedAt) - Date.parse(result!.startedAt)).toBe(1_800_000);
+ expect(result?.durationMinutes).toBe(30);
+ });
+});
+
+// ── distanceMeters ───────────────────────────────────────────────────────────
+
+describe('distanceMeters', () => {
+ it('DAWARICH-GEO-001: a point is zero metres from itself', () => {
+ expect(distanceMeters(52.52, 13.405, 52.52, 13.405)).toBe(0);
+ });
+
+ it('DAWARICH-GEO-002: a small north-south step measures in metres', () => {
+ // 0.0009° of latitude is almost exactly 100 m anywhere on earth.
+ expect(distanceMeters(52.52, 13.405, 52.5209, 13.405)).toBeCloseTo(100, 0);
+ });
+
+ it('DAWARICH-GEO-003: a degree of longitude is ~111 km at the equator', () => {
+ expect(distanceMeters(0, 0, 0, 1)).toBeCloseTo(111_195, -2);
+ });
+
+ it('DAWARICH-GEO-004: a degree of longitude shrinks with the cosine of the latitude', () => {
+ // At 60° north a degree of longitude is half what it is at the equator.
+ expect(distanceMeters(60, 0, 60, 1)).toBeCloseTo(111_195 / 2, -2);
+ });
+
+ it('DAWARICH-GEO-005: distance is symmetric', () => {
+ const there = distanceMeters(48.85, 2.35, 52.52, 13.405);
+ const back = distanceMeters(52.52, 13.405, 48.85, 2.35);
+ expect(there).toBeCloseTo(back, 6);
+ });
+});
+
+// ── simplify ─────────────────────────────────────────────────────────────────
+
+describe('simplify', () => {
+ it('DAWARICH-GEO-006: a straight line collapses to its two ends', () => {
+ expect(simplify([[0, 0], [0, 0.5], [0, 1]], 0.00005)).toEqual([[0, 0], [0, 1]]);
+ });
+
+ it('DAWARICH-GEO-007: a long straight run collapses no matter how many points it has', () => {
+ const straight: Array<[number, number]> = Array.from({ length: 500 }, (_, i) => [0, i / 500]);
+ expect(simplify(straight, 0.00005)).toEqual([[0, 0], [0, 499 / 500]]);
+ });
+
+ it('DAWARICH-GEO-008: a corner survives', () => {
+ expect(simplify([[0, 0], [0, 1], [1, 1]], 0.00005)).toEqual([[0, 0], [0, 1], [1, 1]]);
+ });
+
+ it('DAWARICH-GEO-009: a deviation under the tolerance is dropped, one over it is kept', () => {
+ const jitter: Array<[number, number]> = [[0, 0], [0.00001, 0.5], [0, 1]];
+ const corner: Array<[number, number]> = [[0, 0], [0.001, 0.5], [0, 1]];
+ expect(simplify(jitter, 0.00005)).toHaveLength(2);
+ expect(simplify(corner, 0.00005)).toHaveLength(3);
+ });
+
+ it('DAWARICH-GEO-010: two points or fewer are returned untouched', () => {
+ const pair: Array<[number, number]> = [[0, 0], [1, 1]];
+ expect(simplify(pair, 0.00005)).toBe(pair);
+ expect(simplify([], 0.00005)).toEqual([]);
+ });
+
+ it('DAWARICH-GEO-011: the first and last point are always kept', () => {
+ const zigzag: Array<[number, number]> = [[0, 0], [1, 0.25], [0, 0.5], [1, 0.75], [0, 1]];
+ const out = simplify(zigzag, 0.00005);
+ expect(out[0]).toEqual([0, 0]);
+ expect(out[out.length - 1]).toEqual([0, 1]);
+ });
+});
+
+// ── capPoints ────────────────────────────────────────────────────────────────
+
+describe('capPoints', () => {
+ it('DAWARICH-GEO-012: a line already under the cap is untouched', () => {
+ const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2]];
+ expect(capPoints(line, 5)).toBe(line);
+ expect(capPoints(line, 3)).toBe(line);
+ });
+
+ it('DAWARICH-GEO-013: thins to the cap on an even stride', () => {
+ const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]];
+ expect(capPoints(line, 3)).toEqual([[0, 0], [2, 2], [4, 4]]);
+ });
+
+ it('DAWARICH-GEO-014: the line still starts and ends where the day did', () => {
+ const line: Array<[number, number]> = Array.from({ length: 1000 }, (_, i) => [i / 1000, 0]);
+ const out = capPoints(line, 100);
+ expect(out).toHaveLength(100);
+ expect(out[0]).toEqual(line[0]);
+ expect(out[out.length - 1]).toEqual(line[line.length - 1]);
+ });
+
+ it('DAWARICH-GEO-015: a cap below two is refused rather than producing a degenerate line', () => {
+ const line: Array<[number, number]> = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]];
+ expect(capPoints(line, 1)).toBe(line);
+ expect(capPoints(line, 0)).toBe(line);
+ });
+});
+
+// ── geoJsonToLatLng ──────────────────────────────────────────────────────────
+
+describe('geoJsonToLatLng', () => {
+ it('DAWARICH-GEO-016: swaps GeoJSON [lng, lat] into TREK [lat, lng]', () => {
+ expect(geoJsonToLatLng([[13.4, 52.5]])).toEqual([[52.5, 13.4]]);
+ });
+
+ it('DAWARICH-GEO-017: parses numeric strings as coordinates', () => {
+ expect(geoJsonToLatLng([['13.4', '52.5']])).toEqual([[52.5, 13.4]]);
+ });
+
+ it('DAWARICH-GEO-018: skips pairs that are not pairs', () => {
+ expect(geoJsonToLatLng([[13.4], 'nope', null, {}, []])).toEqual([]);
+ });
+
+ it('DAWARICH-GEO-019: skips pairs that are not numbers', () => {
+ expect(geoJsonToLatLng([['east', 'north'], [null, 52.5], [13.4, undefined]])).toEqual([]);
+ });
+
+ it('DAWARICH-GEO-020: keeps the exact range limits and drops anything past them', () => {
+ expect(geoJsonToLatLng([[180, 90], [-180, -90]])).toEqual([[90, 180], [-90, -180]]);
+ expect(geoJsonToLatLng([[180.1, 10], [-180.1, 10], [10, 90.1], [10, -90.1]])).toEqual([]);
+ });
+
+ it('DAWARICH-GEO-021: keeps the usable pairs out of a mixed list', () => {
+ expect(geoJsonToLatLng([[13.4, 52.5], 'nope', [999, 999], [2.35, 48.85]])).toEqual([
+ [52.5, 13.4],
+ [48.85, 2.35],
+ ]);
+ });
+
+ it('DAWARICH-GEO-022: a non-array is an empty line, not a throw', () => {
+ expect(geoJsonToLatLng('nope')).toEqual([]);
+ expect(geoJsonToLatLng(null)).toEqual([]);
+ expect(geoJsonToLatLng(undefined)).toEqual([]);
+ });
+
+ it('DAWARICH-GEO-023: ignores anything past the first two entries of a pair', () => {
+ // GeoJSON positions may carry an elevation; TREK draws in two dimensions.
+ expect(geoJsonToLatLng([[13.4, 52.5, 34]])).toEqual([[52.5, 13.4]]);
+ });
+});
+
+// ── bucketPointsByDay ────────────────────────────────────────────────────────
+
+describe('bucketPointsByDay', () => {
+ const acrossMidnight = (): DawarichSlimPoint[] => [
+ point('2024-06-15T10:00:00Z', 10, 10),
+ point('2024-06-15T23:59:59Z', 10, 11),
+ point('2024-06-16T00:00:00Z', 11, 11),
+ point('2024-06-16T10:00:00Z', 11, 12),
+ ];
+
+ it('DAWARICH-BUCKET-001: midnight is exact — a point at 00:00:00 belongs to the new day', () => {
+ const days = bucketPointsByDay(acrossMidnight(), 0);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
+ expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T10:00:00.000Z');
+ expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T23:59:59.000Z');
+ expect(days[1]!.segments[0]!.startedAt).toBe('2024-06-16T00:00:00.000Z');
+ expect(days[1]!.segments[0]!.endedAt).toBe('2024-06-16T10:00:00.000Z');
+ });
+
+ it('DAWARICH-BUCKET-002: the seam is stitched — a day starts where the previous one ended', () => {
+ const days = bucketPointsByDay(acrossMidnight(), 0);
+ const previousLast = days[0]!.segments[0]!.points.at(-1);
+ // The stitched point is drawn but does not move the day's own times.
+ expect(days[1]!.segments[0]!.points[0]).toEqual(previousLast);
+ expect(days[1]!.segments[0]!.points).toEqual([[10, 11], [11, 11], [11, 12]]);
+ });
+
+ it('DAWARICH-BUCKET-003: the first day is not stitched to anything', () => {
+ const days = bucketPointsByDay(
+ [point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)],
+ 0,
+ );
+ expect(days[0]!.segments[0]!.points).toEqual([[10, 10], [10, 11]]);
+ });
+
+ it('DAWARICH-BUCKET-004: the offset decides the day — an early UTC point is still yesterday in New York', () => {
+ const days = bucketPointsByDay(
+ [point('2024-06-15T02:00:00Z', 10, 10), point('2024-06-15T03:00:00Z', 11, 11)],
+ -300,
+ );
+ expect(days).toHaveLength(1);
+ expect(days[0]!.date).toBe('2024-06-14');
+ // The segment times stay the real instants; only the bucket is local.
+ expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T02:00:00.000Z');
+ });
+
+ it('DAWARICH-BUCKET-005: a positive offset pushes a late UTC point into tomorrow', () => {
+ const days = bucketPointsByDay(
+ [point('2024-06-15T22:00:00Z', 10, 10), point('2024-06-15T23:00:00Z', 11, 11)],
+ 540,
+ );
+ expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
+ });
+
+ it('DAWARICH-BUCKET-006: points arriving out of order are sorted by time', () => {
+ const days = bucketPointsByDay(
+ [
+ point('2024-06-15T18:00:00Z', 11, 11),
+ point('2024-06-15T06:00:00Z', 10, 10),
+ point('2024-06-15T12:00:00Z', 10, 11),
+ ],
+ 0,
+ );
+ expect(days[0]!.segments[0]!.startedAt).toBe('2024-06-15T06:00:00.000Z');
+ expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-15T18:00:00.000Z');
+ expect(days[0]!.segments[0]!.points[0]).toEqual([10, 10]);
+ });
+
+ it('DAWARICH-BUCKET-007: unusable points are dropped and a day that loses its line is not emitted', () => {
+ const days = bucketPointsByDay(
+ [
+ { latitude: 'north', longitude: 10, timestamp: 1_718_445_600 },
+ { latitude: 95, longitude: 10, timestamp: 1_718_445_601 },
+ { latitude: 10, longitude: 200, timestamp: 1_718_445_602 },
+ { latitude: 10, longitude: 10, timestamp: null } as unknown as DawarichSlimPoint,
+ point('2024-06-15T10:00:00Z', 10, 10),
+ ],
+ 0,
+ );
+ // Only one usable point survived, so there is no line to draw.
+ expect(days).toEqual([]);
+ });
+
+ it('DAWARICH-BUCKET-008: an empty recording is an empty result', () => {
+ expect(bucketPointsByDay([], 0)).toEqual([]);
+ });
+
+ it('DAWARICH-BUCKET-009: honours the per-day cap and the tolerance', () => {
+ const days = bucketPointsByDay(
+ [
+ point('2024-06-15T00:00:00Z', 0, 0),
+ point('2024-06-15T01:00:00Z', 1, 0),
+ point('2024-06-15T02:00:00Z', 0, 1),
+ point('2024-06-15T03:00:00Z', 1, 1),
+ point('2024-06-15T04:00:00Z', 0, 2),
+ ],
+ 0,
+ { maxPointsPerDay: 3, epsilon: 0.00005 },
+ );
+ const line = days[0]!.segments[0]!.points;
+ expect(line).toHaveLength(3);
+ expect(line[0]).toEqual([0, 0]);
+ expect(line.at(-1)).toEqual([0, 2]);
+ });
+
+ it('DAWARICH-BUCKET-010: a recorded day carries no mode and no measured distance', () => {
+ const days = bucketPointsByDay(
+ [point('2024-06-15T10:00:00Z', 10, 10), point('2024-06-15T12:00:00Z', 10, 11)],
+ 0,
+ );
+ expect(days[0]!.segments[0]!.mode).toBeNull();
+ expect(days[0]!.segments[0]!.distanceMeters).toBeNull();
+ });
+});
+
+// ── bucketTracksByDay ────────────────────────────────────────────────────────
+
+describe('bucketTracksByDay', () => {
+ it('DAWARICH-TRACK-001: a track inside one day stays whole and keeps its measured distance', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]], {
+ distance: 4321,
+ dominant_mode: 'car',
+ }),
+ ]);
+ expect(days).toEqual([
+ {
+ date: '2024-06-15',
+ segments: [
+ {
+ // GeoJSON [lng, lat] in, TREK [lat, lng] out.
+ points: [[10, 10], [11, 11], [12, 11]],
+ mode: 'car',
+ startedAt: '2024-06-15T10:00:00Z',
+ endedAt: '2024-06-15T12:00:00Z',
+ distanceMeters: 4321,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it('DAWARICH-TRACK-002: the local day comes from the offset the track carries', () => {
+ // 00:30+02:00 is still 22:30Z the day before, but the recording user saw
+ // the 16th, which is the day TREK must file it under.
+ const days = bucketTracksByDay([
+ track('2024-06-16T00:30:00+02:00', '2024-06-16T01:30:00+02:00', [[10, 10], [11, 11], [11, 12]]),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
+ });
+
+ it('DAWARICH-TRACK-003: a track over midnight is split, and the two halves meet at midnight', () => {
+ const days = bucketTracksByDay([
+ track(
+ '2024-06-15T23:00:00Z',
+ '2024-06-16T01:00:00Z',
+ [[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]],
+ { distance: 9000, dominant_mode: 'walk' },
+ ),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
+
+ const first = days[0]!.segments[0]!;
+ const second = days[1]!.segments[0]!;
+ expect(first.startedAt).toBe('2024-06-15T23:00:00.000Z');
+ expect(first.endedAt).toBe('2024-06-16T00:00:00.000Z');
+ expect(second.startedAt).toBe('2024-06-16T00:00:00.000Z');
+ expect(second.endedAt).toBe('2024-06-16T01:00:00Z');
+ // The cut point belongs to both halves, so the line has no gap.
+ expect(second.points[0]).toEqual(first.points.at(-1));
+ expect(first.mode).toBe('walk');
+ expect(second.mode).toBe('walk');
+ });
+
+ it('DAWARICH-TRACK-004: a split piece reports no distance — only a whole track measured one', () => {
+ const days = bucketTracksByDay([
+ track(
+ '2024-06-15T23:00:00Z',
+ '2024-06-16T01:00:00Z',
+ [[10, 10], [10.5, 10.5], [11, 11], [11.5, 10.5], [12, 11]],
+ { distance: 9000 },
+ ),
+ ]);
+ expect(days.flatMap((d) => d.segments).map((s) => s.distanceMeters)).toEqual([null, null]);
+ });
+
+ it('DAWARICH-TRACK-005: a track spanning three days is cut twice, by index proportion of its span', () => {
+ const days = bucketTracksByDay([
+ track(
+ '2024-06-15T12:00:00Z',
+ '2024-06-17T12:00:00Z',
+ [[10, 10], [10.5, 11], [11, 10], [11.5, 11], [12, 10], [12.5, 11], [13, 10]],
+ { distance: 100 },
+ ),
+ ]);
+ expect(
+ days.map((d) => ({ date: d.date, from: d.segments[0]!.startedAt, to: d.segments[0]!.endedAt })),
+ ).toEqual([
+ { date: '2024-06-15', from: '2024-06-15T12:00:00.000Z', to: '2024-06-16T00:00:00.000Z' },
+ { date: '2024-06-16', from: '2024-06-16T00:00:00.000Z', to: '2024-06-17T00:00:00.000Z' },
+ { date: '2024-06-17', from: '2024-06-17T00:00:00.000Z', to: '2024-06-17T12:00:00Z' },
+ ]);
+ });
+
+ it('DAWARICH-TRACK-006: days come back in calendar order however the features arrived', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-17T08:00:00Z', '2024-06-17T09:00:00Z', [[10, 10], [11, 11], [11, 12]]),
+ track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]),
+ track('2024-06-16T06:00:00Z', '2024-06-16T07:00:00Z', [[30, 30], [31, 31], [31, 32]]),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16', '2024-06-17']);
+ });
+
+ it('DAWARICH-TRACK-007: segments within a day come back in start order', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-15T18:00:00Z', '2024-06-15T19:00:00Z', [[20, 20], [21, 21], [21, 22]]),
+ track('2024-06-15T06:00:00Z', '2024-06-15T07:00:00Z', [[30, 30], [31, 31], [31, 32]]),
+ track('2024-06-15T12:00:00Z', '2024-06-15T13:00:00Z', [[40, 40], [41, 41], [41, 42]]),
+ ]);
+ expect(days).toHaveLength(1);
+ expect(days[0]!.segments.map((s) => s.startedAt)).toEqual([
+ '2024-06-15T06:00:00Z',
+ '2024-06-15T12:00:00Z',
+ '2024-06-15T18:00:00Z',
+ ]);
+ });
+
+ it('DAWARICH-TRACK-008: a track without both timestamps is skipped', () => {
+ const days = bucketTracksByDay([
+ track(undefined, '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]),
+ track('2024-06-15T10:00:00Z', undefined, [[10, 10], [11, 11], [11, 12]]),
+ { geometry: { coordinates: [[10, 10], [11, 11]] }, properties: null },
+ ]);
+ expect(days).toEqual([]);
+ });
+
+ it('DAWARICH-TRACK-009: a track without a drawable line is skipped', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10]]),
+ track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', 'nope'),
+ { geometry: null, properties: { start_at: '2024-06-15T10:00:00Z', end_at: '2024-06-15T12:00:00Z' } },
+ ]);
+ expect(days).toEqual([]);
+ });
+
+ it('DAWARICH-TRACK-010: a backwards or zero-length track is treated as whole, on its start day', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-16T01:00:00Z', '2024-06-15T23:00:00Z', [[10, 10], [11, 11], [11, 12]], {
+ distance: 50,
+ }),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-16']);
+ expect(days[0]!.segments[0]!.distanceMeters).toBe(50);
+ });
+
+ it('DAWARICH-TRACK-011: a two-point track over midnight is not split at all', () => {
+ // One segment cannot become two drawable pieces: cutting it would hand one
+ // day a single point and the other nothing, and the tail would vanish. So
+ // the whole track goes on the day it started, with its real times intact —
+ // the split is declined rather than faked.
+ const days = bucketTracksByDay([
+ track('2024-06-15T23:00:00Z', '2024-06-16T01:00:00Z', [[10, 10], [11, 12]], { distance: 100 }),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-15']);
+ expect(days[0]!.segments[0]!.endedAt).toBe('2024-06-16T01:00:00Z');
+ // Whole, so it still reports the distance Dawarich actually measured.
+ expect(days[0]!.segments[0]!.distanceMeters).toBe(100);
+ });
+
+ it('DAWARICH-TRACK-014: enough points to split across midnight still splits', () => {
+ // The counterpart to 011: three segments over one midnight leave both days
+ // a drawable line, so the approximation is applied.
+ const days = bucketTracksByDay([
+ track('2024-06-15T22:00:00Z', '2024-06-16T02:00:00Z', [[10, 10], [10.5, 10.5], [11, 11], [11.5, 11.5]]),
+ ]);
+ expect(days.map((d) => d.date)).toEqual(['2024-06-15', '2024-06-16']);
+ for (const day of days) expect(day.segments[0]!.points.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('DAWARICH-TRACK-012: honours the per-day cap and the tolerance', () => {
+ const zigzag = Array.from(
+ { length: 40 },
+ (_, i): [number, number] => [10 + i / 10, i % 2 === 0 ? 10 : 11],
+ );
+ const days = bucketTracksByDay([track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', zigzag)], {
+ maxPointsPerDay: 5,
+ epsilon: 0.00005,
+ });
+ expect(days[0]!.segments[0]!.points).toHaveLength(5);
+ });
+
+ it('DAWARICH-TRACK-013: an empty feature list is an empty result', () => {
+ expect(bucketTracksByDay([])).toEqual([]);
+ });
+});
+
+// ── countPoints ──────────────────────────────────────────────────────────────
+
+describe('countPoints', () => {
+ const day = (...segments: Array>): DawarichTrackDay => ({
+ date: '2024-06-15',
+ segments: segments.map((points) => ({
+ points,
+ mode: null,
+ startedAt: '2024-06-15T10:00:00Z',
+ endedAt: '2024-06-15T12:00:00Z',
+ distanceMeters: null,
+ })),
+ });
+
+ it('DAWARICH-COUNT-001: nothing drawn is zero', () => {
+ expect(countPoints([])).toBe(0);
+ expect(countPoints([day()])).toBe(0);
+ });
+
+ it('DAWARICH-COUNT-002: sums every segment of every day', () => {
+ const days = [day([[0, 0], [1, 1]], [[0, 0], [1, 1], [2, 2]]), day([[0, 0], [1, 1]])];
+ expect(countPoints(days)).toBe(7);
+ });
+
+ it('DAWARICH-COUNT-003: agrees with what the bucketing actually produced', () => {
+ const days = bucketTracksByDay([
+ track('2024-06-15T10:00:00Z', '2024-06-15T12:00:00Z', [[10, 10], [11, 11], [11, 12]]),
+ track('2024-06-16T10:00:00Z', '2024-06-16T12:00:00Z', [[20, 20], [21, 21], [21, 22]]),
+ ]);
+ expect(countPoints(days)).toBe(6);
+ });
+});
diff --git a/server/tests/unit/nest/public-api-scopes.test.ts b/server/tests/unit/nest/public-api-scopes.test.ts
new file mode 100644
index 000000000..5e4746e96
--- /dev/null
+++ b/server/tests/unit/nest/public-api-scopes.test.ts
@@ -0,0 +1,681 @@
+/**
+ * Per-key read scopes for /api/v1 (#2279).
+ *
+ * The feature is spread over four files that each know one third of it: the
+ * shared vocabulary, the column and how it is read back (TokenService), the
+ * grant on the request (ApiTokenGuard), and the refusal (public-api-request,
+ * used from two controllers in two modules). Nothing forces those halves to
+ * agree, so the cases below walk the whole path — a row in, a status out — and
+ * pin the one rule that is easy to get backwards on the way:
+ *
+ * a key that was never narrowed reads everything.
+ *
+ * That is not a nicety. Every integration key minted before this shipped has an
+ * empty `api_scopes`, and the day "no list" starts meaning "no access" is the
+ * day every one of them stops working.
+ *
+ * The end-to-end shape (real HTTP, real hash lookup) lives in
+ * tests/e2e/public-api.e2e.test.ts; this file owns the branches that are
+ * awkward to reach through a socket — a NULL column, a key the guard never
+ * touched, an unknown scope name.
+ */
+
+// ---------------------------------------------------------------------------
+// vi.hoisted: real in-memory DB + the module mock, before any import
+// ---------------------------------------------------------------------------
+
+const { testDb, dbMock } = vi.hoisted(() => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const Database = require('better-sqlite3');
+ const db = new Database(':memory:');
+ db.exec('PRAGMA journal_mode = WAL');
+ db.exec('PRAGMA foreign_keys = ON');
+ db.exec('PRAGMA busy_timeout = 5000');
+ return {
+ testDb: db,
+ dbMock: {
+ db,
+ closeDb: () => {},
+ reinitialize: () => {},
+ canAccessTrip: () => undefined,
+ isOwner: () => false,
+ },
+ };
+});
+
+vi.mock('../../../src/db/database', () => dbMock);
+vi.mock('../../../src/nest/auth/ephemeral-tokens', () => ({ createEphemeralToken: vi.fn() }));
+vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn() }));
+vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
+vi.mock('../../../src/nest/audit/audit-log.logger', () => ({
+ LOG_LEVEL: 'error', logInfo: vi.fn(), logDebug: vi.fn(), logError: vi.fn(), logWarn: vi.fn(),
+}));
+
+// ---------------------------------------------------------------------------
+// Imports (after the mocks)
+// ---------------------------------------------------------------------------
+
+import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
+import { HttpException } from '@nestjs/common';
+import type { ExecutionContext } from '@nestjs/common';
+import type { Request } from 'express';
+import {
+ PUBLIC_API_INCLUDES,
+ PUBLIC_API_SCOPES,
+ apiTokenCreateRequestSchema,
+ mcpTokenCreateRequestSchema,
+ type PublicApiGrant,
+ type PublicApiScope,
+} from '@trek/shared';
+import { createTables } from '../../../src/db/schema';
+import { runMigrations } from '../../../src/db/migrations';
+import { resetTestDb } from '../../helpers/test-db';
+import { createUser } from '../../helpers/factories';
+import { DatabaseService } from '../../../src/nest/database/database.service';
+import { TokenService } from '../../../src/nest/tokens/token.service';
+import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
+import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
+import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
+import type { PublicApiService } from '../../../src/nest/public-api/public-api.service';
+import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller';
+import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
+import {
+ grantedScopes,
+ narrowToGrant,
+ requireScope,
+} from '../../../src/nest/public-api/public-api-request';
+import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
+import { AuthController } from '../../../src/nest/auth/auth.controller';
+import type { AuthService } from '../../../src/nest/auth/auth.service';
+import type { AuditService } from '../../../src/nest/audit/audit.service';
+import type { UserProfileService } from '../../../src/nest/auth/user-profile.service';
+import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
+import type { StorageService } from '../../../src/nest/storage/storage.service';
+import type { User } from '../../../src/types';
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const ALL: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
+const limited = (...scopes: PublicApiScope[]): PublicApiGrant => ({ mode: 'limited', scopes });
+
+/** A request as the guard leaves it: a user and a grant, nothing else. */
+const req = (grant: PublicApiGrant | undefined = ALL, userId: number | null = 7) =>
+ ({
+ headers: {},
+ user: userId === null ? undefined : ({ id: userId } as User),
+ apiToken: grant,
+ }) as Request;
+
+const TRIP = {
+ id: 12,
+ title: 'Toskana',
+ description: null,
+ start_date: '2026-06-14',
+ end_date: '2026-06-22',
+ currency: 'EUR',
+ archived: false,
+ updated_at: '2026-06-01 10:00:00',
+};
+
+function thrown(fn: () => unknown): { status: number; body: unknown } {
+ try {
+ fn();
+ } catch (err) {
+ expect(err).toBeInstanceOf(HttpException);
+ const e = err as HttpException;
+ return { status: e.getStatus(), body: e.getResponse() };
+ }
+ throw new Error('expected the handler to refuse');
+}
+
+function apiController(svc: Partial) {
+ const rl = { check: vi.fn().mockReturnValue(true) } as unknown as RateLimitService;
+ return new PublicApiController(svc as PublicApiService, rl);
+}
+
+function statsController() {
+ return new PublicStatsController(
+ {
+ getTravelStats: vi.fn(() => ({
+ countries: ['JP'], cities: ['tokyo'], coords: [],
+ totalTrips: 1, totalDays: 2, totalPlaces: 3, totalDistanceKm: 4,
+ })),
+ lastTrip: vi.fn(() => null),
+ } as unknown as AtlasService,
+ new RateLimitService(),
+ );
+}
+
+// ---------------------------------------------------------------------------
+// The grant on the request
+// ---------------------------------------------------------------------------
+
+describe('public-api scopes — the grant on the request', () => {
+ it('PUBAPI-SCOPE-U001: a request the guard never touched may read nothing', () => {
+ // Fail closed. A route mounted without the guard is a bug, and the answer to
+ // a bug in an authorisation path is "no", never "everything".
+ expect(grantedScopes({} as Request)).toEqual([]);
+ });
+
+ it('PUBAPI-SCOPE-U002: a resolved grant is what the request reports, verbatim', () => {
+ expect(grantedScopes(req(limited('trips', 'days')))).toEqual(['trips', 'days']);
+ expect(grantedScopes(req(ALL))).toEqual([...PUBLIC_API_SCOPES]);
+ });
+
+ it('PUBAPI-SCOPE-U003: a missing scope is a 403 with a code of its own, never a 401 or a 404', () => {
+ // 401 would tell an integrator to re-mint a key that is perfectly valid; 404
+ // would send them looking for a trip that is right there. Both are worse than
+ // saying what actually happened.
+ expect(thrown(() => requireScope(req(limited('trips')), 'stats'))).toEqual({
+ status: 403,
+ body: {
+ error: 'This API key is not allowed to read stats',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'stats',
+ },
+ });
+ });
+
+ it('PUBAPI-SCOPE-U004: a granted scope passes without comment', () => {
+ expect(() => requireScope(req(limited('trips')), 'trips')).not.toThrow();
+ for (const scope of PUBLIC_API_SCOPES) {
+ expect(() => requireScope(req(ALL), scope)).not.toThrow();
+ }
+ });
+
+ it('PUBAPI-SCOPE-U005: narrowToGrant filters instead of refusing, and keeps the caller’s order', () => {
+ expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips', 'days', 'notes')))).toEqual([
+ 'days',
+ 'notes',
+ ]);
+ expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(ALL))).toEqual([...PUBLIC_API_INCLUDES]);
+ expect(narrowToGrant([...PUBLIC_API_INCLUDES], req(limited('trips')))).toEqual([]);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// ApiTokenGuard — resolving the grant
+// ---------------------------------------------------------------------------
+
+describe('ApiTokenGuard — the grant it leaves behind', () => {
+ const USER = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User;
+
+ function contextWith(headers: Record) {
+ const request: Record = { headers };
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ req: request,
+ } as unknown as ExecutionContext & { req: Record };
+ }
+
+ function guardWith(result: { user: User; grant: PublicApiGrant } | null) {
+ const verifyApiTokenWithGrant = vi.fn().mockReturnValue(result);
+ return {
+ guard: new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService),
+ verifyApiTokenWithGrant,
+ };
+ }
+
+ it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', () => {
+ const grant = limited('trips');
+ const { guard, verifyApiTokenWithGrant } = guardWith({ user: USER, grant });
+ const ctx = contextWith({ authorization: 'Bearer trek_abc123' });
+ expect(guard.canActivate(ctx)).toBe(true);
+ expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123');
+ expect(ctx.req.user).toBe(USER);
+ expect(ctx.req.apiToken).toBe(grant);
+ // The grant belongs to the credential, not to the person: nothing of it may
+ // end up on `user`, which every other auth path in TREK also reads.
+ expect(USER).not.toHaveProperty('scopes');
+ expect(USER).not.toHaveProperty('scope_mode');
+ });
+
+ /**
+ * The two refusals predate scopes and are load-bearing for every integration
+ * that already handles them. Adding a third answer must not reword them.
+ */
+ it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', () => {
+ const { guard: noCredential } = guardWith(null);
+ expect(thrown(() => noCredential.canActivate(contextWith({})))).toEqual({
+ status: 401,
+ body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
+ });
+ const { guard: unknownToken } = guardWith(null);
+ expect(
+ thrown(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
+ ).toEqual({
+ status: 401,
+ body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
+ });
+ });
+
+ it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', () => {
+ const { guard } = guardWith(null);
+ const ctx = contextWith({ authorization: 'Bearer trek_nope' });
+ thrown(() => guard.canActivate(ctx));
+ expect(ctx.req.apiToken).toBeUndefined();
+ expect(ctx.req.user).toBeUndefined();
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The two controllers behind the guard
+// ---------------------------------------------------------------------------
+
+describe('PublicApiController — what a narrowed key reaches', () => {
+ it('PUBAPI-SCOPE-U020: an un-narrowed key reads every route, exactly as before', () => {
+ const listTrips = vi.fn().mockReturnValue([TRIP]);
+ const listBucketList = vi.fn().mockReturnValue([]);
+ const getTrip = vi.fn().mockReturnValue(TRIP);
+ const ctl = apiController({ listTrips, listBucketList, getTrip });
+ expect(ctl.listTrips(req(ALL))).toEqual({ trips: [TRIP] });
+ expect(ctl.listBucketList(req(ALL))).toEqual({ items: [] });
+ expect(ctl.getTrip(req(ALL), '12', undefined)).toEqual(TRIP);
+ expect(getTrip).toHaveBeenCalledWith(12, 7, [...PUBLIC_API_INCLUDES]);
+ });
+
+ it('PUBAPI-SCOPE-U021: a trips-only key reads the trip list', () => {
+ const listTrips = vi.fn().mockReturnValue([TRIP]);
+ expect(apiController({ listTrips }).listTrips(req(limited('trips')))).toEqual({ trips: [TRIP] });
+ });
+
+ it('PUBAPI-SCOPE-U022: and is refused the bucket list before the service is ever asked', () => {
+ const listBucketList = vi.fn();
+ const res = thrown(() => apiController({ listBucketList }).listBucketList(req(limited('trips'))));
+ expect(res.status).toBe(403);
+ expect(res.body).toMatchObject({ code: 'API_SCOPE_FORBIDDEN', required_scope: 'bucket-list' });
+ // The refusal has to happen before the read, not after it: a 403 carrying
+ // rows that were already fetched is one careless `return` from leaking them.
+ expect(listBucketList).not.toHaveBeenCalled();
+ });
+
+ it('PUBAPI-SCOPE-U023: a key without `trips` cannot reach the list or a single trip', () => {
+ const listTrips = vi.fn();
+ const getTrip = vi.fn();
+ const ctl = apiController({ listTrips, getTrip });
+ expect(thrown(() => ctl.listTrips(req(limited('bucket-list')))).status).toBe(403);
+ expect(thrown(() => ctl.getTrip(req(limited('bucket-list')), '12', undefined)).status).toBe(403);
+ expect(listTrips).not.toHaveBeenCalled();
+ expect(getTrip).not.toHaveBeenCalled();
+ });
+
+ it('PUBAPI-SCOPE-U024: a trip asked for without include is narrowed, not refused', () => {
+ const getTrip = vi.fn().mockReturnValue(TRIP);
+ apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', undefined);
+ // `include` absent means "everything"; refusing a key for wanting sections it
+ // never named would make a narrow key unable to read a trip at all.
+ expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']);
+ });
+
+ it('PUBAPI-SCOPE-U025: a trips-only key gets the summary and nothing that hangs off it', () => {
+ const getTrip = vi.fn().mockReturnValue(TRIP);
+ apiController({ getTrip }).getTrip(req(limited('trips')), '12', undefined);
+ expect(getTrip).toHaveBeenCalledWith(12, 7, []);
+ });
+
+ it('PUBAPI-SCOPE-U026: an empty include is treated like an absent one, and narrowed too', () => {
+ const getTrip = vi.fn().mockReturnValue(TRIP);
+ apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', ' ');
+ expect(getTrip).toHaveBeenCalledWith(12, 7, ['days']);
+ });
+
+ it('PUBAPI-SCOPE-U027: a section named explicitly and not granted is a 403, not a silent drop', () => {
+ const getTrip = vi.fn();
+ const res = thrown(() =>
+ apiController({ getTrip }).getTrip(req(limited('trips', 'days')), '12', 'days,places'),
+ );
+ expect(res).toEqual({
+ status: 403,
+ body: {
+ error: 'This API key is not allowed to read places',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'places',
+ },
+ });
+ expect(getTrip).not.toHaveBeenCalled();
+ });
+
+ it('PUBAPI-SCOPE-U028: an include the key covers is passed through untouched', () => {
+ const getTrip = vi.fn().mockReturnValue(TRIP);
+ apiController({ getTrip }).getTrip(req(limited('trips', 'days', 'notes')), '12', 'days, notes');
+ expect(getTrip).toHaveBeenCalledWith(12, 7, ['days', 'notes']);
+ });
+
+ it('PUBAPI-SCOPE-U029: a bad id is still a 400 — the scope check does not swallow it', () => {
+ const getTrip = vi.fn();
+ expect(thrown(() => apiController({ getTrip }).getTrip(req(ALL), 'abc', undefined)).status).toBe(400);
+ expect(getTrip).not.toHaveBeenCalled();
+ });
+});
+
+describe('PublicStatsController — the widest answer on the surface', () => {
+ it('PUBAPI-SCOPE-U030: a key without `stats` is refused, in the module next door too', () => {
+ // The route lives in atlas/ and imports the check from public-api/. If it ever
+ // stops calling it, nothing else in that module would notice.
+ const res = thrown(() => statsController().stats(req(limited('trips'))));
+ expect(res).toEqual({
+ status: 403,
+ body: {
+ error: 'This API key is not allowed to read stats',
+ code: 'API_SCOPE_FORBIDDEN',
+ required_scope: 'stats',
+ },
+ });
+ });
+
+ it('PUBAPI-SCOPE-U031: a key that was granted stats still gets its numbers', () => {
+ expect(statsController().stats(req(limited('stats')))).toMatchObject({
+ total_trips: 1,
+ total_countries: 1,
+ });
+ });
+
+ it('PUBAPI-SCOPE-U032: an un-narrowed key reads stats, as every key did before', () => {
+ expect(statsController().stats(req(ALL))).toMatchObject({ total_trips: 1 });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// The column: what is written, and what is read back out of it
+// ---------------------------------------------------------------------------
+
+describe('TokenService — storing and resolving a grant', () => {
+ const tokens = new TokenService(new DatabaseService(testDb), new EphemeralTokenService());
+
+ beforeAll(() => {
+ createTables(testDb);
+ runMigrations(testDb);
+ });
+
+ beforeEach(() => {
+ resetTestDb(testDb);
+ vi.clearAllMocks();
+ });
+
+ afterAll(() => {
+ testDb.close();
+ });
+
+ const rowFor = (id: number) =>
+ testDb.prepare('SELECT kind, scope_mode, api_scopes FROM mcp_tokens WHERE id = ?').get(id) as {
+ kind: string;
+ scope_mode: string | null;
+ api_scopes: string | null;
+ };
+
+ const mint = (userId: number, name: string, scopes?: readonly string[]) =>
+ tokens.createApiToken(userId, name, scopes).token as {
+ id: number;
+ raw_token: string;
+ scope_mode: string;
+ scopes: string[];
+ };
+
+ it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich');
+ // 'all' is written explicitly: a NULL sentinel here would mean a bug that
+ // drops the column silently grants everything.
+ expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null });
+ expect(created.scope_mode).toBe('all');
+ expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]);
+ });
+
+ it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', () => {
+ const { user } = createUser(testDb);
+ const raw = mint(user.id, 'Dawarich').raw_token;
+ const resolved = tokens.verifyApiTokenWithGrant(raw);
+ expect(resolved?.user.id).toBe(user.id);
+ expect(resolved?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES] });
+ });
+
+ /**
+ * The backwards-compatibility case, at the layer that decides it. A row written
+ * before the columns existed carries neither value; the ALTER backfills
+ * 'all', and a DatabaseService that hands back NULL anyway must land in the
+ * same place. Both are "nobody ever narrowed this key".
+ */
+ it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', () => {
+ const noColumns = new TokenService(
+ {
+ get: () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }),
+ run: () => ({ changes: 1 }),
+ } as unknown as DatabaseService,
+ new EphemeralTokenService(),
+ );
+ expect(noColumns.verifyApiTokenWithGrant('trek_legacy')?.grant).toEqual({
+ mode: 'all',
+ scopes: [...PUBLIC_API_SCOPES],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['stats', 'trips']);
+ expect(rowFor(created.id)).toEqual({
+ kind: 'api',
+ scope_mode: 'limited',
+ // Canonical order, not the caller's: two keys with the same access must
+ // read identically in the settings list.
+ api_scopes: JSON.stringify(['trips', 'stats']),
+ });
+ expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] });
+ });
+
+ it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', () => {
+ const { user } = createUser(testDb);
+ const raw = mint(user.id, 'Dawarich', ['trips', 'days']).raw_token;
+ expect(tokens.verifyApiTokenWithGrant(raw)?.grant).toEqual({
+ mode: 'limited',
+ scopes: ['trips', 'days'],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']);
+ expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips']));
+ });
+
+ it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', () => {
+ const { user } = createUser(testDb);
+ // Not an empty limited grant: that would mint a key that can read nothing,
+ // which nobody asked for and which fails hours later at the integration.
+ const created = mint(user.id, 'Dawarich', ['everything']);
+ expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
+ });
+
+ it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', () => {
+ const { user } = createUser(testDb);
+ expect(rowFor(mint(user.id, 'Dawarich', []).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
+ });
+
+ it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', () => {
+ // The direction matters. A row that never said 'limited' is a legacy
+ // full-access key and stays one; a row that HAS said it is restricted must
+ // never widen because its list became unreadable — that is the failure this
+ // whole column exists to prevent. A key that stops working is a support
+ // ticket; a key that quietly reads every trip is the bug.
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['trips']);
+ for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) {
+ testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id);
+ expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
+ mode: 'limited',
+ scopes: [],
+ });
+ }
+ });
+
+ it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['trips']);
+ // What a future version dropping or renaming a scope constant leaves behind.
+ testDb
+ .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
+ .run(JSON.stringify(['itineraries']), created.id);
+ expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
+ mode: 'limited',
+ scopes: [],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['trips']);
+ testDb
+ .prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
+ .run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id);
+ expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
+ mode: 'limited',
+ scopes: ['trips', 'stats'],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', () => {
+ const { user } = createUser(testDb);
+ mint(user.id, 'Wide');
+ mint(user.id, 'Narrow', ['trips']);
+ const listed = tokens.listApiTokens(user.id) as Array>;
+ expect(listed).toHaveLength(2);
+ expect(listed.map((t) => [t.name, t.scope_mode, t.scopes])).toEqual(
+ expect.arrayContaining([
+ ['Wide', 'all', [...PUBLIC_API_SCOPES]],
+ ['Narrow', 'limited', ['trips']],
+ ]),
+ );
+ // Still no hash, and still no raw key.
+ expect(JSON.stringify(listed)).not.toContain('token_hash');
+ });
+
+ it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', () => {
+ const { user } = createUser(testDb);
+ const mcp = tokens.createMcpToken(user.id, 'Assistant').token as { id: number };
+ // Stored as the column default, because an MCP token carries no read scopes
+ // and must not look as if it did.
+ expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
+ const listed = tokens.listMcpTokens(user.id) as Array>;
+ expect(listed).toHaveLength(1);
+ // The MCP panel cannot act on scopes, so it is not shown two columns that
+ // would always say the same thing.
+ expect(listed[0]).not.toHaveProperty('scope_mode');
+ expect(listed[0]).not.toHaveProperty('scopes');
+ expect(Object.keys(listed[0]).sort()).toEqual(
+ ['created_at', 'id', 'last_used_at', 'name', 'token_prefix'].sort(),
+ );
+ });
+
+ it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', () => {
+ const { user } = createUser(testDb);
+ const raw = mint(user.id, 'Dawarich', ['trips']).raw_token;
+ expect(tokens.verifyMcpToken(raw)).toBeNull();
+ expect(tokens.verifyApiTokenWithGrant(raw)).not.toBeNull();
+ });
+
+ it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', () => {
+ const { user } = createUser(testDb);
+ const created = mint(user.id, 'Dawarich', ['trips']);
+ expect(
+ (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
+ last_used_at: string | null;
+ }).last_used_at,
+ ).toBeNull();
+ tokens.verifyApiTokenWithGrant(created.raw_token);
+ expect(
+ (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
+ last_used_at: string | null;
+ }).last_used_at,
+ ).not.toBeNull();
+ });
+
+ // -------------------------------------------------------------------------
+ // The mint routes. AuthController is constructed directly (repo convention)
+ // over the real TokenService above, so the body really does reach the column.
+ // -------------------------------------------------------------------------
+
+ const authController = () =>
+ new AuthController(
+ { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() } as unknown as AuthService,
+ {} as UserProfileService,
+ tokens,
+ new RateLimitService(),
+ { writeAudit: vi.fn() } as unknown as AuditService,
+ new RuntimeEnvService(),
+ { put: vi.fn() } as unknown as StorageService,
+ );
+ const httpReq = { ip: '9.9.9.9', headers: {} } as Request;
+
+ it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', () => {
+ const { user } = createUser(testDb);
+ const created = authController().createApiToken(
+ { id: user.id } as User,
+ { name: 'Dawarich', scopes: ['trips', 'days'] },
+ httpReq,
+ ).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string };
+
+ expect(rowFor(created.id)).toMatchObject({
+ scope_mode: 'limited',
+ api_scopes: JSON.stringify(['trips', 'days']),
+ });
+ // The response carries the grant, so the panel can show it without a re-read.
+ expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'days'] });
+ expect(created.raw_token).toMatch(/^trek_/);
+ });
+
+ it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', () => {
+ const { user } = createUser(testDb);
+ const ctl = authController();
+ ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq);
+ expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
+ tokens: [expect.objectContaining({ name: 'Dawarich', scope_mode: 'limited', scopes: ['bucket-list'] })],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', () => {
+ const { user } = createUser(testDb);
+ const ctl = authController();
+ const created = ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq).token as {
+ id: number;
+ };
+ expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
+ expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
+ tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })],
+ });
+ });
+
+ it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', () => {
+ const { user } = createUser(testDb);
+ const ctl = authController();
+ // The DTO is a different schema on purpose, so the field never survives the
+ // pipe — and even handed straight to the controller it changes nothing.
+ const created = ctl.createMcpToken(
+ { id: user.id } as User,
+ { name: 'Assistant', scopes: ['trips'] } as { name: string },
+ httpReq,
+ ).token as Record;
+
+ expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
+ expect(created).not.toHaveProperty('scope_mode');
+ expect(created).not.toHaveProperty('scopes');
+ expect(ctl.listMcpTokens({ id: user.id } as User).tokens[0]).not.toHaveProperty('scope_mode');
+ });
+
+ it('PUBAPI-SCOPE-U064: the two create schemas differ, which is what keeps them apart', () => {
+ const api = apiTokenCreateRequestSchema.safeParse({ name: 'Dawarich', scopes: ['trips'] });
+ expect(api.success && api.data.scopes).toEqual(['trips']);
+ // An unknown section is refused at the edge rather than quietly sanitised, so
+ // a typo in an integration's setup script is visible immediately.
+ expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: ['passwords'] }).success).toBe(false);
+ // An empty array would mint a key that reads nothing; omitting the field is
+ // how you ask for everything.
+ expect(apiTokenCreateRequestSchema.safeParse({ name: 'x', scopes: [] }).success).toBe(false);
+ expect(apiTokenCreateRequestSchema.safeParse({ name: 'x' }).success).toBe(true);
+
+ const mcp = mcpTokenCreateRequestSchema.safeParse({ name: 'Assistant', scopes: ['trips'] });
+ expect(mcp.success).toBe(true);
+ // Parsed and dropped: the MCP route cannot be handed read scopes by accident.
+ expect(mcp.success && mcp.data).toEqual({ name: 'Assistant' });
+ });
+});
diff --git a/server/tests/unit/nest/public-api.controller.test.ts b/server/tests/unit/nest/public-api.controller.test.ts
index bf4710f94..afa1cc6ce 100644
--- a/server/tests/unit/nest/public-api.controller.test.ts
+++ b/server/tests/unit/nest/public-api.controller.test.ts
@@ -9,7 +9,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
-import { PUBLIC_API_INCLUDES } from '@trek/shared';
+import { PUBLIC_API_INCLUDES, PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared';
import { PublicApiController } from '../../../src/nest/public-api/public-api.controller';
import type { PublicApiService } from '../../../src/nest/public-api/public-api.service';
import type { RateLimitService } from '../../../src/nest/common/rate-limit.service';
@@ -40,9 +40,15 @@ function makeController(svc: Partial, allow = true) {
return new PublicApiController(svc as PublicApiService, rl);
}
-const req = (userId = 7) => ({ user: { id: userId } }) as Request;
-/** A request the guard never touched — the shape the controller must refuse. */
-const reqWithoutUser = () => ({}) as Request;
+/**
+ * The guard leaves two things behind (#2279): who is calling, and what their key
+ * may read. Every request here carries the full grant, because these cases are
+ * about shaping; the narrowed ones live in public-api-scopes.test.ts.
+ */
+const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
+const req = (userId = 7) => ({ user: { id: userId }, apiToken: FULL_GRANT }) as Request;
+/** A request the guard left no user on — the shape the controller must refuse. */
+const reqWithoutUser = () => ({ apiToken: FULL_GRANT }) as Request;
function thrown(fn: () => unknown): { status: number; body: unknown } {
try {
diff --git a/server/tests/unit/nest/public-api.guard.test.ts b/server/tests/unit/nest/public-api.guard.test.ts
index a58ebd12d..a5094534e 100644
--- a/server/tests/unit/nest/public-api.guard.test.ts
+++ b/server/tests/unit/nest/public-api.guard.test.ts
@@ -8,6 +8,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { ExecutionContext } from '@nestjs/common';
+import { PUBLIC_API_SCOPES } from '@trek/shared';
import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard';
import type { TokenService } from '../../../src/nest/tokens/token.service';
import type { User } from '../../../src/types';
@@ -22,8 +23,17 @@ function contextWith(headers: Record) {
} as unknown as ExecutionContext & { req: Record };
}
+/**
+ * The guard resolves the user and the key's read grant in one lookup (#2279).
+ * These cases are about the credential, so every accepted token here carries the
+ * full grant; what a narrowed one may read is public-api-scopes.test.ts.
+ */
function makeGuard(verify: (raw: string) => User | null) {
- return new ApiTokenGuard({ verifyApiToken: verify } as unknown as TokenService);
+ const verifyApiTokenWithGrant = (raw: string) => {
+ const user = verify(raw);
+ return user ? { user, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } } : null;
+ };
+ return new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService);
}
/** Run the guard, expecting a refusal; return its { status, body }. */
@@ -70,11 +80,13 @@ describe('ApiTokenGuard', () => {
});
it('never reaches for the MCP verifier, so the two credentials cannot swap', () => {
- const verifyApiToken = vi.fn().mockReturnValue(USER);
+ const verifyApiTokenWithGrant = vi
+ .fn()
+ .mockReturnValue({ user: USER, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } });
const verifyMcpToken = vi.fn();
- const guard = new ApiTokenGuard({ verifyApiToken, verifyMcpToken } as unknown as TokenService);
+ const guard = new ApiTokenGuard({ verifyApiTokenWithGrant, verifyMcpToken } as unknown as TokenService);
guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' }));
- expect(verifyApiToken).toHaveBeenCalledTimes(1);
+ expect(verifyApiTokenWithGrant).toHaveBeenCalledTimes(1);
expect(verifyMcpToken).not.toHaveBeenCalled();
});
diff --git a/server/tests/unit/nest/public-stats.controller.test.ts b/server/tests/unit/nest/public-stats.controller.test.ts
index aaae15837..50f24b113 100644
--- a/server/tests/unit/nest/public-stats.controller.test.ts
+++ b/server/tests/unit/nest/public-stats.controller.test.ts
@@ -14,6 +14,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import type { Request } from 'express';
+import { PUBLIC_API_SCOPES, type PublicApiGrant } from '@trek/shared';
import { PublicStatsController } from '../../../src/nest/atlas/public-stats.controller';
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
import { RateLimitService } from '../../../src/nest/common/rate-limit.service';
@@ -22,8 +23,15 @@ import type { User } from '../../../src/types';
// `null` rather than `undefined` for "no user": passing undefined would trip the
// default parameter and hand back a request that still has one.
+// `apiToken` is the read grant the guard resolves alongside the user (#2279);
+// the full one here, so these cases still test what they always tested.
+const FULL_GRANT: PublicApiGrant = { mode: 'all', scopes: [...PUBLIC_API_SCOPES] };
const req = (userId: number | null = 7) =>
- ({ headers: {}, user: userId === null ? undefined : ({ id: userId } as User) }) as Request;
+ ({
+ headers: {},
+ user: userId === null ? undefined : ({ id: userId } as User),
+ apiToken: FULL_GRANT,
+ }) as Request;
const travel = (o: Partial> = {}) => ({
countries: ['JP', 'IT'],
diff --git a/server/vitest.config.ts b/server/vitest.config.ts
index 7032ec653..7a20d615e 100644
--- a/server/vitest.config.ts
+++ b/server/vitest.config.ts
@@ -87,7 +87,7 @@ export default defineConfig({
'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 },
'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 },
'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 },
- 'src/nest/integrations/**/*.ts': { statements: 72, branches: 56, functions: 76, lines: 74 },
+ 'src/nest/integrations/**/*.ts': { statements: 78, branches: 68, functions: 78, lines: 80 },
'src/nest/journey/**/*.ts': { statements: 91, branches: 84, functions: 88, lines: 93 },
'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 },
'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 },
diff --git a/shared/src/dawarich/dawarich.schema.ts b/shared/src/dawarich/dawarich.schema.ts
new file mode 100644
index 000000000..612379e23
--- /dev/null
+++ b/shared/src/dawarich/dawarich.schema.ts
@@ -0,0 +1,431 @@
+import { z } from 'zod';
+import { idSchema } from '../common/primitives.schema';
+
+/**
+ * Dawarich integration contracts (#2279, #214).
+ *
+ * Dawarich (github.com/Freika/dawarich) is a self-hosted location-history
+ * tracker. The split of duties is deliberate and settled: **Dawarich records,
+ * TREK plans and interprets.** TREK never starts a location tracker of its own,
+ * never writes into Dawarich, and never keeps a copy of the raw GPS archive —
+ * the track overlay is fetched for the window being looked at and thrown away
+ * again.
+ *
+ * The connection is per user (Settings → Integrations); the global on/off is
+ * the `dawarich` addon. Each user stores their own instance URL and API key,
+ * which reaches exactly that user's own recordings.
+ *
+ * Everything Dawarich produces arrives as a **suggestion owned by the person
+ * who connected the instance**. Nothing reaches a shared trip until they accept
+ * it, because a background poll that edits a trip three other people are
+ * planning is not a feature.
+ */
+
+// ── Per-user connection ──────────────────────────────────────────────────────
+
+/** Placeholder returned instead of the stored key. Never the key itself. */
+export const DAWARICH_KEY_MASK = '••••••••';
+
+export const dawarichSettingsSchema = z.object({
+ /** Instance origin, e.g. https://dawarich.example.com — TREK appends /api/v1 itself. */
+ url: z.string().trim().max(2048),
+ /** API key. Omitted, blank or the mask keeps the stored key unchanged. */
+ apiKey: z.string().max(512).optional(),
+ /** Allow self-signed TLS certificates, which LAN instances routinely use. */
+ allowInsecureTls: z.boolean().optional().default(false),
+ /**
+ * Poll for new visits in the background. Off means the user pulls by hand —
+ * some people would rather their trip planner not talk to their location
+ * history on a timer.
+ */
+ syncEnabled: z.boolean().optional().default(true),
+});
+export type DawarichSettings = z.infer;
+
+/**
+ * What the connected instance actually offers, probed once per connect and
+ * re-probed on every successful sync.
+ *
+ * This exists because three things were discussed with Dawarich's maintainer
+ * and none of them had shipped as of 1.14.4: an `updated_at` on a visit, a
+ * status filter on the visits endpoint, and an ISO-3166 country code on the
+ * visit's place. TREK works without all three — change detection falls back to
+ * a content hash, status filtering happens here, and the country is derived
+ * from the coordinates — and picks the better path up automatically if a later
+ * Dawarich version starts sending them. A missing capability is never a reason
+ * to refuse to connect.
+ */
+export const dawarichCapabilitiesSchema = z.object({
+ /** `GET /api/v1/visits` answered. Without it there are no suggestions. */
+ visits: z.boolean(),
+ /** `GET /api/v1/tracks` answered — pre-segmented lines, the good overlay source. */
+ tracks: z.boolean(),
+ /** `GET /api/v1/points` answered — the fallback overlay source. */
+ points: z.boolean(),
+ /** `GET /api/v1/locations` answered — used to tick off bucket-list entries. */
+ locations: z.boolean(),
+ /** `GET /api/v1/countries/visited_cities` answered — feeds the Atlas. */
+ visitedCities: z.boolean(),
+ /** A visit carries `updated_at`. As of 1.14.4 it does not. */
+ visitUpdatedAt: z.boolean(),
+ /** A visit's place carries a country code. As of 1.14.4 it does not. */
+ visitCountryCode: z.boolean(),
+ /** Reported by `X-Dawarich-Version`, so it is the instance's own word. */
+ serverVersion: z.string().nullable(),
+ /** When this probe ran, ISO-8601. */
+ probedAt: z.string(),
+});
+export type DawarichCapabilities = z.infer;
+
+/** How the last sync attempt ended — what the connection card reports. */
+export const DAWARICH_SYNC_STATES = ['never', 'ok', 'partial', 'failed'] as const;
+export const dawarichSyncStateSchema = z.enum(DAWARICH_SYNC_STATES);
+export type DawarichSyncState = z.infer;
+
+export const dawarichConnectionSchema = z.object({
+ url: z.string(),
+ apiKeyMasked: z.string(),
+ allowInsecureTls: z.boolean(),
+ syncEnabled: z.boolean(),
+ connected: z.boolean(),
+ /** ISO-8601 of the last sync that finished, successfully or not. */
+ lastSyncAt: z.string().nullable(),
+ lastSyncState: dawarichSyncStateSchema,
+ /**
+ * Why the last sync did not fully succeed, already translated by the server
+ * into one of the reason codes below. Null when it did.
+ */
+ lastSyncError: z.string().nullable(),
+ capabilities: dawarichCapabilitiesSchema.nullable(),
+});
+export type DawarichConnection = z.infer;
+
+/**
+ * Why a Dawarich call failed, as a code rather than a sentence.
+ *
+ * The client renders these through i18n. A raw upstream message would reach the
+ * user in English on a German install, and some of them quote the URL back.
+ */
+export const DAWARICH_ERROR_CODES = [
+ 'unreachable',
+ 'unauthorized',
+ 'forbidden',
+ 'not_found',
+ 'rate_limited',
+ 'server_error',
+ 'invalid_response',
+ 'too_large',
+ 'not_connected',
+ 'addon_disabled',
+] as const;
+export const dawarichErrorCodeSchema = z.enum(DAWARICH_ERROR_CODES);
+export type DawarichErrorCode = z.infer;
+
+export const dawarichStatusSchema = z.object({
+ connected: z.boolean(),
+ /** Visits found in the probe window, so the user sees the connection did something. */
+ visitCount: z.number().optional(),
+ error: dawarichErrorCodeSchema.optional(),
+ /** Free-text detail for the cases where the code alone is not actionable. */
+ errorDetail: z.string().optional(),
+ capabilities: dawarichCapabilitiesSchema.optional(),
+});
+export type DawarichStatus = z.infer;
+
+// ── Visit suggestions ────────────────────────────────────────────────────────
+
+/**
+ * What Dawarich thinks about a visit. Since 1.12.0 declining a visit **deletes**
+ * it, so only these two remain — and, crucially, an unconfirmed visit is
+ * displayed in Dawarich exactly like a confirmed one. Importing only
+ * `confirmed` would leave most instances looking empty, which is the failure
+ * mode the maintainer specifically warned about.
+ */
+export const DAWARICH_VISIT_STATUSES = ['suggested', 'confirmed'] as const;
+export const dawarichVisitStatusSchema = z.enum(DAWARICH_VISIT_STATUSES);
+export type DawarichVisitStatus = z.infer;
+
+/** Where a suggestion stands in TREK. Independent of what Dawarich thinks of it. */
+export const DAWARICH_SUGGESTION_STATES = ['new', 'accepted', 'dismissed'] as const;
+export const dawarichSuggestionStateSchema = z.enum(DAWARICH_SUGGESTION_STATES);
+export type DawarichSuggestionState = z.infer;
+
+/** What accepting a suggestion produced. */
+export const DAWARICH_SUGGESTION_TARGETS = ['place', 'journal', 'bucket_list'] as const;
+export const dawarichSuggestionTargetSchema = z.enum(DAWARICH_SUGGESTION_TARGETS);
+export type DawarichSuggestionTarget = z.infer;
+
+/**
+ * One recorded stay, as TREK holds it for review.
+ *
+ * `source_changed` and `source_missing` are the honest answer to a question the
+ * API cannot answer directly: Dawarich sends no `updated_at`, and deleting a
+ * visit removes it from the list rather than marking it. So a changed visit is
+ * detected by comparing a hash of the fields TREK shows, and a removed one by
+ * noticing it was absent from a full re-read of the same window. Neither ever
+ * rewrites something the user already accepted and possibly edited — they raise
+ * a flag and leave the decision where it belongs.
+ */
+export const dawarichSuggestionSchema = z.object({
+ id: idSchema,
+ /** Dawarich's own visit id. The dedup key, together with the user. */
+ sourceVisitId: z.string(),
+ /** The trip whose dates this stay fell into when it was first seen. */
+ tripId: idSchema.nullable(),
+ tripTitle: z.string().nullable(),
+ name: z.string(),
+ lat: z.number().nullable(),
+ lng: z.number().nullable(),
+ /** ISO-8601 with offset, exactly as Dawarich reported it. */
+ startedAt: z.string(),
+ endedAt: z.string(),
+ /** The stay's length in minutes, the number the review list sorts and filters on. */
+ durationMinutes: z.number(),
+ /**
+ * The local calendar date this stay belongs to, resolved in the trip's own
+ * timezone. A stay that starts at 23:40 belongs to that evening, not to the
+ * next morning because UTC says so.
+ */
+ localDate: z.string(),
+ sourceStatus: dawarichVisitStatusSchema,
+ /**
+ * Dawarich's own confidence in the detection, 0..100. Null when its detector
+ * reported none — older rows and manually created visits have none.
+ */
+ confidence: z.number().nullable(),
+ /**
+ * The same number as a word: `high` (>= 70), `medium` (>= 40), `low`.
+ * Kept as a plain string rather than an enum, so a band a later Dawarich adds
+ * arrives intact instead of failing validation for the whole suggestion.
+ */
+ confidenceBand: z.string().nullable(),
+ state: dawarichSuggestionStateSchema,
+ target: dawarichSuggestionTargetSchema.nullable(),
+ acceptedPlaceId: idSchema.nullable(),
+ acceptedJournalEntryId: idSchema.nullable(),
+ acceptedBucketListItemId: idSchema.nullable(),
+ /** The source row changed since this was accepted — shown, never auto-applied. */
+ sourceChanged: z.boolean(),
+ /** The source row is gone from Dawarich — shown, never auto-deleted. */
+ sourceMissing: z.boolean(),
+ /** A bucket-list entry this stay sits on top of, if one matched. */
+ matchedBucketListItemId: idSchema.nullable(),
+ matchedBucketListName: z.string().nullable(),
+ /** ISO-3166-1 alpha-2, derived from the coordinates — Dawarich sends no code. */
+ countryCode: z.string().nullable(),
+ firstSeenAt: z.string(),
+ lastSeenAt: z.string(),
+});
+export type DawarichSuggestion = z.infer;
+
+export const dawarichSuggestionListSchema = z.object({
+ suggestions: z.array(dawarichSuggestionSchema),
+ /** Repeated here so a panel can show provenance without a second request. */
+ connected: z.boolean(),
+ lastSyncAt: z.string().nullable(),
+ lastSyncState: dawarichSyncStateSchema,
+ lastSyncError: z.string().nullable(),
+});
+export type DawarichSuggestionList = z.infer;
+
+/**
+ * Accepting a suggestion. Every field the user could correct before saving is
+ * here, because the review step exists precisely so a detector's guess can be
+ * fixed rather than swallowed.
+ */
+export const dawarichAcceptSchema = z.object({
+ target: dawarichSuggestionTargetSchema,
+ /** Required for `place`; optional context for `journal`. */
+ tripId: idSchema.optional(),
+ /** Which day of the trip the place lands on. Omitted leaves it unplanned. */
+ dayId: idSchema.optional(),
+ /** Required for `journal`. */
+ journalId: idSchema.optional(),
+ /** Required for `bucket_list` — which wish this stay ticks off. */
+ bucketListItemId: idSchema.optional(),
+ /** Corrected name. Falls back to the suggestion's own. */
+ name: z.string().trim().min(1).max(255).optional(),
+ notes: z.string().max(5000).optional(),
+ lat: z.number().min(-90).max(90).optional(),
+ lng: z.number().min(-180).max(180).optional(),
+ /** `YYYY-MM-DD`. Falls back to the suggestion's resolved local date. */
+ date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
+ /** `HH:MM`. Falls back to the recorded arrival and departure. */
+ time: z.string().regex(/^\d{2}:\d{2}$/).optional(),
+ endTime: z.string().regex(/^\d{2}:\d{2}$/).optional(),
+});
+export type DawarichAccept = z.infer;
+
+export const dawarichAcceptResultSchema = z.object({
+ suggestion: dawarichSuggestionSchema,
+ /** What was created, so the caller can navigate straight to it. */
+ createdPlaceId: idSchema.nullable(),
+ createdJournalEntryId: idSchema.nullable(),
+ bucketListItemId: idSchema.nullable(),
+});
+export type DawarichAcceptResult = z.infer;
+
+/** Putting a dismissed or accepted suggestion back into review. */
+export const dawarichSuggestionStateSchemaBody = z.object({
+ state: z.enum(['new', 'dismissed']),
+});
+export type DawarichSuggestionStateBody = z.infer;
+
+// ── Track overlay ────────────────────────────────────────────────────────────
+
+/**
+ * How a segment was travelled, as Dawarich classified it. Passed through rather
+ * than mapped onto TREK's own transport modes: the two vocabularies do not line
+ * up (Dawarich has `stationary`, TREK has `ferry`), and inventing a mapping
+ * would put a confident wrong label on a recorded fact.
+ */
+export const dawarichTrackSegmentSchema = z.object({
+ /** `[lat, lng]` pairs, matching how TREK stores every other track. */
+ points: z.array(z.tuple([z.number(), z.number()])),
+ /** Dawarich's `dominant_mode`, or null on an instance too old to report one. */
+ mode: z.string().nullable(),
+ startedAt: z.string(),
+ endedAt: z.string(),
+ distanceMeters: z.number().nullable(),
+});
+export type DawarichTrackSegment = z.infer;
+
+/** One local calendar day of recorded movement. */
+export const dawarichTrackDaySchema = z.object({
+ /** `YYYY-MM-DD` in the trip's timezone. */
+ date: z.string(),
+ segments: z.array(dawarichTrackSegmentSchema),
+});
+export type DawarichTrackDay = z.infer;
+
+/**
+ * The recorded route for a window, as fetched — not as stored. Nothing here is
+ * written to TREK's database; the next request asks Dawarich again.
+ */
+export const dawarichTrackSchema = z.object({
+ days: z.array(dawarichTrackDaySchema),
+ /**
+ * Which endpoint answered. `tracks` is pre-segmented and cheap; `points` is
+ * the fallback for an instance that has not generated tracks yet, thinned
+ * before it leaves the server.
+ */
+ source: z.enum(['tracks', 'points']),
+ /** ISO-8601 of the fetch, so the map can say how fresh the line is. */
+ fetchedAt: z.string(),
+ /** Points after thinning, across all days. */
+ pointCount: z.number(),
+ /** The window hit the page cap and the line is incomplete. Said out loud, never hidden. */
+ truncated: z.boolean(),
+});
+export type DawarichTrack = z.infer;
+
+// ── Atlas ────────────────────────────────────────────────────────────────────
+
+/**
+ * A country Dawarich's recordings say the user was in, offered to the Atlas.
+ *
+ * Offered, not applied: the Atlas is a thing people curate, and a poll that
+ * quietly ticks off countries would overwrite a deliberate choice. Entries
+ * already marked by hand come back with `alreadyVisited` so they are shown as
+ * confirmation rather than as something new.
+ */
+export const dawarichAtlasCountrySchema = z.object({
+ /** ISO-3166-1 alpha-2, resolved from Dawarich's country name. */
+ countryCode: z.string(),
+ /** Dawarich's own spelling, kept so an unresolved country can still be named. */
+ sourceName: z.string(),
+ cities: z.array(
+ z.object({
+ name: z.string(),
+ /** How long the recordings put the user there, in minutes. */
+ minutes: z.number(),
+ /**
+ * ISO-8601 of the most recent point in that city, or null when the
+ * instance sent none. Nullable rather than defaulted: a city stamped
+ * 1970-01-01 looks like data and is not.
+ */
+ lastSeenAt: z.string().nullable(),
+ }),
+ ),
+ alreadyVisited: z.boolean(),
+});
+export type DawarichAtlasCountry = z.infer;
+
+export const dawarichAtlasSuggestionsSchema = z.object({
+ countries: z.array(dawarichAtlasCountrySchema),
+ /** Dawarich country names that matched no ISO code — named rather than dropped. */
+ unresolved: z.array(z.string()),
+ fetchedAt: z.string(),
+});
+export type DawarichAtlasSuggestions = z.infer;
+
+export const dawarichAtlasAcceptSchema = z.object({
+ countryCodes: z.array(z.string().length(2)).min(1).max(300),
+});
+export type DawarichAtlasAccept = z.infer;
+
+// ── Bucket list ──────────────────────────────────────────────────────────────
+
+/**
+ * Result of asking Dawarich whether the user ever reached a place on their
+ * bucket list.
+ *
+ * Proximity alone is not a visit — driving past a cathedral is not visiting it —
+ * so a match needs both a distance and a dwell time, and both are reported so
+ * the user can judge the call rather than trust it.
+ */
+export const dawarichBucketMatchSchema = z.object({
+ itemId: idSchema,
+ name: z.string(),
+ /** Null when the recordings show nothing near it. */
+ match: z
+ .object({
+ /** ISO-8601 of the stay's start. */
+ at: z.string(),
+ minutes: z.number(),
+ distanceMeters: z.number(),
+ /** How many recorded points backed the match. */
+ points: z.number(),
+ })
+ .nullable(),
+ /** Already ticked off before this scan ran. */
+ alreadyVisited: z.boolean(),
+});
+export type DawarichBucketMatch = z.infer;
+
+export const dawarichBucketScanSchema = z.object({
+ matches: z.array(dawarichBucketMatchSchema),
+ /** Entries skipped because they carry no coordinates — nothing to match against. */
+ skippedWithoutCoordinates: z.number(),
+ /** The scan stopped at the per-run cap; run it again for the rest. */
+ truncated: z.boolean(),
+ fetchedAt: z.string(),
+});
+export type DawarichBucketScan = z.infer;
+
+export const dawarichBucketConfirmSchema = z.object({
+ itemIds: z.array(idSchema).min(1).max(200),
+ /** ISO-8601 the visit is recorded at. Defaults to the matched stay. */
+ visitedAt: z.string().optional(),
+});
+export type DawarichBucketConfirm = z.infer;
+
+// ── Tuning ───────────────────────────────────────────────────────────────────
+
+/**
+ * What counts as being somewhere.
+ *
+ * These are TREK's numbers, not Dawarich's: Dawarich decides where a visit was,
+ * TREK decides whether that is enough to call a wish fulfilled. Kept next to the
+ * contract so the server, the tests and the help text cannot disagree.
+ */
+export const DAWARICH_BUCKET_MATCH_RADIUS_M = 250;
+export const DAWARICH_BUCKET_MATCH_MIN_MINUTES = 20;
+/** Bucket-list entries examined per scan — one upstream request each. */
+export const DAWARICH_BUCKET_SCAN_LIMIT = 50;
+/** Points kept per day after thinning, so a month-long trip stays drawable. */
+export const DAWARICH_TRACK_POINTS_PER_DAY = 600;
+/** How far back a sync looks for trips that have no end date yet. */
+export const DAWARICH_SYNC_LOOKBACK_DAYS = 3;
+/** Days past a trip's end still worth polling — a stay can end after midnight. */
+export const DAWARICH_SYNC_LOOKAHEAD_DAYS = 1;
diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts
index 318080a93..cb101b5ce 100644
--- a/shared/src/i18n/ar/admin.ts
+++ b/shared/src/i18n/ar/admin.ts
@@ -612,6 +612,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'استيراد الأماكن من قائمة Naver Maps مشتركة',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'مزامنة الرحلات من خادم AirTrail الخاص بك',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'قراءة الزيارات والمسارات المسجَّلة من خادم Dawarich يربطه كل قارئ بنفسه',
'admin.addons.catalog.llm_parsing.name': 'التحليل بالذكاء الاصطناعي',
'admin.addons.catalog.llm_parsing.description': 'يقرأ الحجوزات التي يعجز المحلل المدمج عن فهمها، بنموذج ذكاء اصطناعي تختاره',
'admin.addons.enabled': 'مفعّل',
diff --git a/shared/src/i18n/ar/dawarich.ts b/shared/src/i18n/ar/dawarich.ts
new file mode 100644
index 000000000..64b5be2fe
--- /dev/null
+++ b/shared/src/i18n/ar/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'اربط خادم Dawarich الخاص بك لترى أين ذهبت فعلاً. يقرأه TREK ويقترح مدوّنات وأماكن ودولاً — لا يُضاف شيء قبل أن تؤكده، ولا يُكتب أي شيء في Dawarich.',
+ 'dawarich.url': 'عنوان الخادم',
+ 'dawarich.apiKey': 'مفتاح API',
+ 'dawarich.apiKeyPlaceholder': 'الصق مفتاح API الخاص بـ Dawarich',
+ 'dawarich.apiKeyHint': 'تجده في Dawarich ضمن الحساب ← مفتاح API. يُخزَّن مشفَّراً ولا يُعرض مرة أخرى.',
+ 'dawarich.allowInsecureTls': 'السماح بشهادة موقّعة ذاتياً',
+ 'dawarich.allowInsecureTlsHint': 'لا يلزم إلا إذا كان خادمك يستخدم شهادة لا يثق بها خادمك.',
+ 'dawarich.syncEnabled': 'التحقق من الإقامات الجديدة تلقائياً',
+ 'dawarich.syncEnabledHint': 'عند الإيقاف، لا يقرأ TREK من Dawarich إلا حين تطلب ذلك.',
+ 'dawarich.test.button': 'اختبار الاتصال',
+ 'dawarich.test.success': 'تم الاتصال. تم العثور على {count} إقامة خلال آخر 30 يوماً.',
+ 'dawarich.test.failed': 'تعذّر الوصول إلى Dawarich.',
+ 'dawarich.syncNow': 'تحقق الآن',
+ 'dawarich.connected': 'متصل',
+ 'dawarich.notConnected': 'غير متصل',
+ 'dawarich.disconnect': 'قطع الاتصال',
+ 'dawarich.lastSync': 'آخر تحقق {when}',
+ 'dawarich.neverSynced': 'لم يتم التحقق بعد',
+ 'dawarich.syncPartial': 'تعذّرت قراءة بعض الرحلات',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'تم حفظ اتصال Dawarich',
+ 'dawarich.toast.saveError': 'تعذّر حفظ الاتصال',
+ 'dawarich.toast.disconnected': 'تم قطع الاتصال بـ Dawarich',
+ 'dawarich.toast.synced': 'تم العثور على {count} إقامة جديدة',
+ 'dawarich.toast.syncError': 'تعذّرت القراءة من Dawarich',
+ 'dawarich.toast.syncRunning': 'هناك فحص قيد التشغيل بالفعل',
+ 'dawarich.toast.acceptError': 'تعذّرت إضافة هذا',
+ 'dawarich.toast.updateError': 'تعذّر تحديث هذا الاقتراح',
+ 'dawarich.toast.accepted.place': 'تمت الإضافة إلى الرحلة',
+ 'dawarich.toast.accepted.journal': 'تمت الإضافة إلى المجلة',
+ 'dawarich.toast.accepted.bucket_list': 'تم شطبها من قائمة أمنياتك',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'الإقامات',
+ 'dawarich.capability.track': 'المسار المسجَّل',
+ 'dawarich.capability.locations': 'مطابقة قائمة الأمنيات',
+ 'dawarich.capability.visitedCities': 'الدول والمدن',
+ 'dawarich.capability.missing': 'هذه النسخة من Dawarich لا توفّر: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'تعذّر على TREK الوصول إلى هذا العنوان.',
+ 'dawarich.error.unauthorized': 'رفض Dawarich مفتاح API.',
+ 'dawarich.error.forbidden': 'هذا المفتاح غير مسموح له بقراءة هذا.',
+ 'dawarich.error.not_found': 'هذه النسخة من Dawarich لا تحتوي على هذه الواجهة.',
+ 'dawarich.error.rate_limited': 'طلب Dawarich من TREK التمهّل. حاول مرة أخرى بعد قليل.',
+ 'dawarich.error.server_error': 'ردّ Dawarich بخطأ.',
+ 'dawarich.error.invalid_response': 'ردّ هذا العنوان بشيء ليس Dawarich.',
+ 'dawarich.error.too_large': 'أرسل Dawarich بيانات أكثر مما يقرأه TREK دفعة واحدة.',
+ 'dawarich.error.not_connected': 'لا يوجد خادم Dawarich متصل بعد.',
+ 'dawarich.error.addon_disabled': 'إضافة Dawarich معطّلة على هذا الخادم.',
+ 'dawarich.error.offline': 'يحتاج هذا إلى اتصال — TREK غير متصل حالياً.',
+ 'dawarich.error.invalid_url': 'لا يمكن لـ TREK استخدام هذا العنوان.',
+ 'dawarich.warning.private_ip': 'هذا العنوان يشير إلى عنوان IP خاص ({ip}). تأكد أن هذا ما تقصده — قد يحتاج الخادم إلى ALLOW_INTERNAL_NETWORK=true للوصول إليه.',
+ 'dawarich.error.unknown': 'حدث خطأ ما أثناء التواصل مع Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'إظهار المسار المسجَّل',
+ 'dawarich.trail.hide': 'إخفاء المسار المسجَّل',
+ 'dawarich.trail.loading': 'جارٍ تحميل المسار المسجَّل…',
+ 'dawarich.trail.empty': 'لم يُسجَّل شيء في هذه التواريخ',
+ 'dawarich.trail.offline': 'يحتاج المسار المسجَّل إلى اتصال',
+ 'dawarich.trail.unavailable': 'تعذّر تحميل المسار المسجَّل',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} د',
+ 'dawarich.duration.hours': '{hours} س',
+ 'dawarich.duration.hoursMinutes': '{hours} س {minutes} د',
+ 'dawarich.checkedAgo': 'تم التحقق {ago}',
+
+ 'dawarich.badge.lowConfidence': 'غير مؤكد',
+ 'dawarich.badge.sourceChanged': 'تغيّر في Dawarich',
+ 'dawarich.badge.sourceMissing': 'اختفى من Dawarich',
+
+ 'dawarich.suggestions.title': 'من Dawarich',
+ 'dawarich.suggestions.pending': '{count} في انتظارك',
+ 'dawarich.suggestions.loading': 'جارٍ القراءة من Dawarich…',
+ 'dawarich.suggestions.notConnected': 'اربط Dawarich من الإعدادات لترى إقاماتك هنا.',
+ 'dawarich.suggestions.unavailable': 'تعذّرت القراءة من Dawarich.',
+ 'dawarich.suggestions.allHandled': 'تمت معالجة كل ما سُجِّل هنا.',
+ 'dawarich.suggestions.asJournal': 'كتابة مدوّنة',
+ 'dawarich.suggestions.asPlace': 'إضافة كمكان',
+ 'dawarich.suggestions.dismiss': 'ليس مكاناً زرته',
+ 'dawarich.suggestions.dismissed': 'تم التجاهل',
+ 'dawarich.suggestions.restore': 'إعادته',
+ 'dawarich.suggestions.showHandled': 'إظهار {count} تمت معالجتها',
+ 'dawarich.suggestions.hideHandled': 'إخفاء ما تمت معالجته',
+ 'dawarich.suggestions.matchesWish': 'في قائمة أمنياتك: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'تمت الإضافة كمكان',
+ 'dawarich.suggestions.acceptedAs.journal': 'في المجلة',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'تم شطب الأمنية',
+ 'dawarich.suggestions.sourceChanged':
+ 'تغيّرت هذه الإقامة في Dawarich منذ أن استخدمتها. ما كتبته في TREK لم يُمَس.',
+ 'dawarich.suggestions.sourceMissing':
+ 'لم تعد هذه الإقامة موجودة في Dawarich. ما كتبته في TREK لم يُمَس.',
+ 'dawarich.sourceStatus.suggested': 'مُكتشَفة، غير مؤكدة',
+ 'dawarich.confidence.high': 'اكتشاف موثوق',
+ 'dawarich.confidence.medium': 'اكتشاف موثوق إلى حدٍّ ما',
+ 'dawarich.confidence.low': 'اكتشاف غير مؤكد',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'إضافة هذه الإقامة كمكان',
+ 'dawarich.accept.title.journal': 'كتابة مدوّنة',
+ 'dawarich.accept.title.bucket_list': 'شطب أمنية',
+ 'dawarich.accept.confirm.place': 'إضافة المكان',
+ 'dawarich.accept.confirm.journal': 'إضافة المدوّنة',
+ 'dawarich.accept.confirm.bucket_list': 'اشطبها',
+ 'dawarich.accept.recorded': 'مسجَّلة من {from} حتى {to}',
+ 'dawarich.accept.duration': '{minutes} دقيقة',
+ 'dawarich.accept.name': 'الاسم',
+ 'dawarich.accept.date': 'التاريخ',
+ 'dawarich.accept.from': 'الوصول',
+ 'dawarich.accept.to': 'المغادرة',
+ 'dawarich.accept.trip': 'الرحلة',
+ 'dawarich.accept.thisTrip': 'هذه الرحلة',
+ 'dawarich.accept.pickTrip': 'اختر رحلة',
+ 'dawarich.accept.day': 'اليوم',
+ 'dawarich.accept.noDay': 'ليست ضمن يوم بعد',
+ 'dawarich.accept.journal': 'المجلة',
+ 'dawarich.accept.pickJournal': 'اختر مجلة',
+ 'dawarich.accept.notes': 'ملاحظات',
+ 'dawarich.accept.story': 'قصتك',
+ 'dawarich.accept.storyPlaceholder': 'ماذا حدث هنا؟',
+ 'dawarich.accept.photosHint': 'أضف الصور إلى المدوّنة بعد إنشائها.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'أُضيف من تسجيلات Dawarich الخاصة بك',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'قارن قائمة أمنياتك بـ Dawarich',
+ 'dawarich.bucket.description':
+ 'يبحث في تسجيلاتك عن الأماكن التي أردت الوصول إليها. الزيارة تحتاج قرباً ووقتاً معاً — المرور بالسيارة لا يُحتسب.',
+ 'dawarich.bucket.scan': 'فحص قائمة الأمنيات',
+ 'dawarich.bucket.scanning': 'جارٍ الفحص…',
+ 'dawarich.bucket.noMatches': 'لم يظهر أي شيء من قائمة أمنياتك في تسجيلاتك.',
+ 'dawarich.bucket.alreadyVisited': 'مشطوبة مسبقاً',
+ 'dawarich.bucket.confirm': 'اشطب {count}',
+ 'dawarich.bucket.confirmed': 'تم شطب {count} أمنية',
+ 'dawarich.bucket.skipped': '{count} مُدخلاً بلا إحداثيات وتعذّر فحصها.',
+ 'dawarich.bucket.truncated': 'تم فحص المُدخلات الأولى فقط. شغّل الفحص مجدداً للبقية.',
+ 'dawarich.bucket.visitedFrom': 'شُطبت اعتماداً على تسجيلات Dawarich',
+ 'dawarich.bucket.clearVisit': 'تراجع',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'الدول من Dawarich',
+ 'dawarich.atlas.description':
+ 'الدول التي تقول تسجيلاتك إنك كنت فيها. أكّد ما تريد إضافته إلى أطلسك — لا يُضاف شيء من تلقاء نفسه، وما وضعته بيدك يبقى لك.',
+ 'dawarich.atlas.load': 'ابحث عن الدول',
+ 'dawarich.atlas.loading': 'جارٍ قراءة تسجيلاتك…',
+ 'dawarich.atlas.empty': 'لا تُظهر تسجيلاتك أي دول ليست لدى TREK أصلاً.',
+ 'dawarich.atlas.cities': '{count} مدينة',
+ 'dawarich.atlas.citiesOne': 'مدينة واحدة',
+ 'dawarich.atlas.accept': 'إضافة {count} دولة',
+ 'dawarich.atlas.accepted': 'تمت إضافة {count} دولة',
+ 'dawarich.atlas.unresolved': 'تعذّر على TREK مطابقة هذه بدولة: {names}.',
+ 'dawarich.atlas.source': 'من Dawarich',
+ 'dawarich.atlas.range': 'تم الاطلاع على الفترة من {from} إلى {to}',
+
+ 'dawarich.atlas.trigger': 'الأمنيات والبلدان من تسجيلاتك',
+ 'dawarich.atlas.dialogSubtitle': 'ما تقوله تسجيلاتك عن أطلسك',
+ 'dawarich.atlas.tab.wishes': 'قائمة الأمنيات',
+ 'dawarich.atlas.tab.countries': 'البلدان',
+ 'dawarich.atlas.window': 'تم فحص آخر 12 شهرًا.',
+ 'dawarich.selected': 'تم تحديد {count}',
+ 'dawarich.again': 'تحقق مرة أخرى',
+ 'dawarich.bucket.metersAway': 'على بُعد {meters} م',
+ 'dawarich.bucket.kilometersAway': 'على بُعد {km} كم',
+ 'dawarich.bucket.rule': 'تُحتسب الأمنية محققة ضمن {meters} م وبعد {minutes} دقيقة في المكان.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/ar/index.ts
+++ b/shared/src/i18n/ar/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts
index f66ee48f0..2e4405b2a 100644
--- a/shared/src/i18n/ar/settings.ts
+++ b/shared/src/i18n/ar/settings.ts
@@ -537,6 +537,21 @@ const settings: TranslationStrings = {
'يعرض تلقائيًا مسار كل رحلة طيران وقطار وحجز آخر على الخريطة، دون الحاجة إلى تفعيله لكل عنصر على حدة.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'ما يمكن لهذا المفتاح قراءته',
+ 'settings.apiScopes.hint':
+ 'اترك كل شيء مفعّلاً لمفتاح ينبغي أن يرى كل شيء. أي خيار توقفه يُرفض لهذا المفتاح، لا يُحذف من الإجابة فحسب.',
+ 'settings.apiScopes.all': 'كل شيء',
+ 'settings.apiScopes.limited': '{count} من {total}',
+ 'settings.apiScopes.trips': 'الرحلات',
+ 'settings.apiScopes.days': 'الأيام',
+ 'settings.apiScopes.places': 'الأماكن',
+ 'settings.apiScopes.notes': 'ملاحظات اليوم',
+ 'settings.apiScopes.reservations': 'الحجوزات',
+ 'settings.apiScopes.accommodations': 'الإقامة',
+ 'settings.apiScopes.travellers': 'من سيأتي',
+ 'settings.apiScopes.bucket-list': 'قائمة الأمنيات',
+ 'settings.apiScopes.stats': 'الإجماليات',
'settings.apiKeys.title': 'مفاتيح API',
'settings.apiKeys.description': 'مفاتيح لواجهة API العامة، حتى تتمكن برامج أخرى من قراءة رحلاتك. للقراءة فقط: لا يمكن للمفتاح تغيير أي شيء أو حذفه.',
'settings.apiKeys.create': 'إنشاء مفتاح',
diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts
index 60db5f161..125618074 100644
--- a/shared/src/i18n/br/admin.ts
+++ b/shared/src/i18n/br/admin.ts
@@ -605,6 +605,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importe lugares de uma lista compartilhada do Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sincronize voos da sua instância AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Leia visitas e rotas gravadas de uma instância do Dawarich que cada leitor conecta por conta própria',
'admin.addons.catalog.llm_parsing.name': 'Análise por IA',
'admin.addons.catalog.llm_parsing.description': 'Lê reservas que o analisador integrado não entende, usando um modelo de IA à sua escolha',
'admin.addons.enabled': 'Ativado',
diff --git a/shared/src/i18n/br/dawarich.ts b/shared/src/i18n/br/dawarich.ts
new file mode 100644
index 000000000..73fe3f5b8
--- /dev/null
+++ b/shared/src/i18n/br/dawarich.ts
@@ -0,0 +1,180 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Conecte sua própria instância do Dawarich para ver onde você realmente esteve. O TREK lê os dados e sugere entradas de diário, lugares e países — nada é adicionado antes de você confirmar, e nada é gravado de volta no Dawarich.',
+ 'dawarich.url': 'Endereço da instância',
+ 'dawarich.apiKey': 'Chave de API',
+ 'dawarich.apiKeyPlaceholder': 'Cole sua chave de API do Dawarich',
+ 'dawarich.apiKeyHint':
+ 'Encontrada no Dawarich em Conta → Chave de API. Guardada criptografada e nunca exibida de novo.',
+ 'dawarich.allowInsecureTls': 'Permitir certificado autoassinado',
+ 'dawarich.allowInsecureTlsHint':
+ 'Só é necessário se sua instância usa um certificado em que seu servidor não confia.',
+ 'dawarich.syncEnabled': 'Procurar novas estadias automaticamente',
+ 'dawarich.syncEnabledHint': 'Desligado significa que o TREK só lê o Dawarich quando você pedir.',
+ 'dawarich.test.button': 'Testar conexão',
+ 'dawarich.test.success': 'Conectado. {count} estadias encontradas nos últimos 30 dias.',
+ 'dawarich.test.failed': 'Não foi possível acessar o Dawarich.',
+ 'dawarich.syncNow': 'Procurar agora',
+ 'dawarich.connected': 'Conectado',
+ 'dawarich.notConnected': 'Não conectado',
+ 'dawarich.disconnect': 'Desconectar',
+ 'dawarich.lastSync': 'Última verificação {when}',
+ 'dawarich.neverSynced': 'Ainda não verificado',
+ 'dawarich.syncPartial': 'algumas viagens não puderam ser lidas',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Conexão com o Dawarich salva',
+ 'dawarich.toast.saveError': 'Não foi possível salvar a conexão',
+ 'dawarich.toast.disconnected': 'Dawarich desconectado',
+ 'dawarich.toast.synced': '{count} novas estadias encontradas',
+ 'dawarich.toast.syncError': 'Não foi possível ler o Dawarich',
+ 'dawarich.toast.syncRunning': 'Já há uma verificação em andamento',
+ 'dawarich.toast.acceptError': 'Não foi possível adicionar isso',
+ 'dawarich.toast.updateError': 'Não foi possível atualizar esta sugestão',
+ 'dawarich.toast.accepted.place': 'Adicionado à viagem',
+ 'dawarich.toast.accepted.journal': 'Adicionado ao diário',
+ 'dawarich.toast.accepted.bucket_list': 'Riscado da sua lista de desejos',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'estadias',
+ 'dawarich.capability.track': 'rota gravada',
+ 'dawarich.capability.locations': 'correspondência com a lista de desejos',
+ 'dawarich.capability.visitedCities': 'países e cidades',
+ 'dawarich.capability.missing': 'Esta versão do Dawarich não oferece: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'O TREK não conseguiu acessar esse endereço.',
+ 'dawarich.error.unauthorized': 'O Dawarich recusou a chave de API.',
+ 'dawarich.error.forbidden': 'Essa chave de API não tem permissão para ler isto.',
+ 'dawarich.error.not_found': 'Esta versão do Dawarich não tem esse endpoint.',
+ 'dawarich.error.rate_limited': 'O Dawarich pediu que o TREK diminuísse o ritmo. Tente de novo em instantes.',
+ 'dawarich.error.server_error': 'O Dawarich respondeu com um erro.',
+ 'dawarich.error.invalid_response': 'Esse endereço respondeu com algo que não é o Dawarich.',
+ 'dawarich.error.too_large': 'O Dawarich enviou mais dados do que o TREK lê de uma vez.',
+ 'dawarich.error.not_connected': 'Nenhuma instância do Dawarich está conectada ainda.',
+ 'dawarich.error.addon_disabled': 'O complemento Dawarich está desativado nesta instância.',
+ 'dawarich.error.offline': 'Isso precisa de conexão — o TREK está offline agora.',
+ 'dawarich.error.invalid_url': 'O TREK não pode usar esse endereço.',
+ 'dawarich.warning.private_ip': 'Esse endereço aponta para um IP privado ({ip}). Confirme se é isso mesmo — o servidor pode precisar de ALLOW_INTERNAL_NETWORK=true para alcançá-lo.',
+ 'dawarich.error.unknown': 'Algo deu errado na conversa com o Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Mostrar rota gravada',
+ 'dawarich.trail.hide': 'Ocultar rota gravada',
+ 'dawarich.trail.loading': 'Carregando a rota gravada…',
+ 'dawarich.trail.empty': 'Nada foi gravado nessas datas',
+ 'dawarich.trail.offline': 'A rota gravada precisa de conexão',
+ 'dawarich.trail.unavailable': 'Não foi possível carregar a rota gravada',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'verificado {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Incerto',
+ 'dawarich.badge.sourceChanged': 'Alterado no Dawarich',
+ 'dawarich.badge.sourceMissing': 'Sumiu do Dawarich',
+
+ 'dawarich.suggestions.title': 'Do Dawarich',
+ 'dawarich.suggestions.pending': '{count} esperando por você',
+ 'dawarich.suggestions.loading': 'Lendo o Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Conecte o Dawarich nas Configurações para ver suas estadias aqui.',
+ 'dawarich.suggestions.unavailable': 'Não foi possível ler o Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Tudo o que foi gravado aqui já foi resolvido.',
+ 'dawarich.suggestions.asJournal': 'Escrever uma entrada no diário',
+ 'dawarich.suggestions.asPlace': 'Adicionar como lugar',
+ 'dawarich.suggestions.dismiss': 'Não é um lugar que visitei',
+ 'dawarich.suggestions.dismissed': 'Dispensado',
+ 'dawarich.suggestions.restore': 'Colocar de volta',
+ 'dawarich.suggestions.showHandled': 'Mostrar {count} já resolvidas',
+ 'dawarich.suggestions.hideHandled': 'Ocultar as que já foram resolvidas',
+ 'dawarich.suggestions.matchesWish': 'Na sua lista de desejos: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Adicionado como lugar',
+ 'dawarich.suggestions.acceptedAs.journal': 'No diário',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Desejo riscado',
+ 'dawarich.suggestions.sourceChanged':
+ 'Esta estadia mudou no Dawarich depois que você a usou. O que você escreveu no TREK continua intacto.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Esta estadia não existe mais no Dawarich. O que você escreveu no TREK continua intacto.',
+ 'dawarich.sourceStatus.suggested': 'Detectada, não confirmada',
+ 'dawarich.confidence.high': 'Detecção confiável',
+ 'dawarich.confidence.medium': 'Detecção razoavelmente confiável',
+ 'dawarich.confidence.low': 'Detecção incerta',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Adicionar esta estadia como lugar',
+ 'dawarich.accept.title.journal': 'Escrever uma entrada no diário',
+ 'dawarich.accept.title.bucket_list': 'Riscar um desejo',
+ 'dawarich.accept.confirm.place': 'Adicionar lugar',
+ 'dawarich.accept.confirm.journal': 'Adicionar entrada',
+ 'dawarich.accept.confirm.bucket_list': 'Riscar',
+ 'dawarich.accept.recorded': 'Gravado de {from} até {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nome',
+ 'dawarich.accept.date': 'Data',
+ 'dawarich.accept.from': 'Chegada',
+ 'dawarich.accept.to': 'Saída',
+ 'dawarich.accept.trip': 'Viagem',
+ 'dawarich.accept.thisTrip': 'Esta viagem',
+ 'dawarich.accept.pickTrip': 'Escolha uma viagem',
+ 'dawarich.accept.day': 'Dia',
+ 'dawarich.accept.noDay': 'Ainda sem dia definido',
+ 'dawarich.accept.journal': 'Diário',
+ 'dawarich.accept.pickJournal': 'Escolha um diário',
+ 'dawarich.accept.notes': 'Notas',
+ 'dawarich.accept.story': 'Sua história',
+ 'dawarich.accept.storyPlaceholder': 'O que aconteceu aqui?',
+ 'dawarich.accept.photosHint': 'Adicione fotos à entrada depois que ela for criada.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Adicionado a partir dos seus registros do Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Conferir sua lista de desejos com o Dawarich',
+ 'dawarich.bucket.description':
+ 'Procura nas suas gravações os lugares que você queria conhecer. Uma visita precisa de proximidade e de tempo parado — passar de carro não conta.',
+ 'dawarich.bucket.scan': 'Conferir lista de desejos',
+ 'dawarich.bucket.scanning': 'Conferindo…',
+ 'dawarich.bucket.noMatches': 'Nada da sua lista de desejos apareceu nas suas gravações.',
+ 'dawarich.bucket.alreadyVisited': 'Já riscado',
+ 'dawarich.bucket.confirm': 'Riscar {count}',
+ 'dawarich.bucket.confirmed': '{count} desejos riscados',
+ 'dawarich.bucket.skipped': '{count} entradas não têm coordenadas e não puderam ser conferidas.',
+ 'dawarich.bucket.truncated': 'Só as primeiras entradas foram conferidas. Rode de novo para as demais.',
+ 'dawarich.bucket.visitedFrom': 'Riscado a partir das suas gravações do Dawarich',
+ 'dawarich.bucket.clearVisit': 'Desfazer',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Países do Dawarich',
+ 'dawarich.atlas.description':
+ 'Países em que suas gravações dizem que você esteve. Confirme os que quiser no seu Atlas — nada é adicionado por conta própria, e o que você marcou à mão continua seu.',
+ 'dawarich.atlas.load': 'Procurar países',
+ 'dawarich.atlas.loading': 'Lendo suas gravações…',
+ 'dawarich.atlas.empty': 'Suas gravações não mostram países que o TREK ainda não tenha.',
+ 'dawarich.atlas.cities': '{count} cidades',
+ 'dawarich.atlas.citiesOne': '1 cidade',
+ 'dawarich.atlas.accept': 'Adicionar {count} países',
+ 'dawarich.atlas.accepted': '{count} países adicionados',
+ 'dawarich.atlas.unresolved': 'O TREK não conseguiu associar estes a um país: {names}.',
+ 'dawarich.atlas.source': 'Do Dawarich',
+ 'dawarich.atlas.range': 'Analisado de {from} a {to}',
+
+ 'dawarich.atlas.trigger': 'Desejos e países dos seus registros',
+ 'dawarich.atlas.dialogSubtitle': 'O que seus registros dizem sobre o seu Atlas',
+ 'dawarich.atlas.tab.wishes': 'Lista de desejos',
+ 'dawarich.atlas.tab.countries': 'Países',
+ 'dawarich.atlas.window': 'Foram analisados os últimos 12 meses.',
+ 'dawarich.selected': '{count} selecionados',
+ 'dawarich.again': 'Verificar de novo',
+ 'dawarich.bucket.metersAway': 'a {meters} m',
+ 'dawarich.bucket.kilometersAway': 'a {km} km',
+ 'dawarich.bucket.rule': 'Um desejo conta como alcançado a até {meters} m e após {minutes} minutos no local.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts
index 1bb07ab5e..66df25441 100644
--- a/shared/src/i18n/br/index.ts
+++ b/shared/src/i18n/br/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts
index e8086a5ef..17001478c 100644
--- a/shared/src/i18n/br/settings.ts
+++ b/shared/src/i18n/br/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Mostra automaticamente no mapa a rota de cada voo, trem e outra reserva, sem precisar ativar item por item.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'O que esta chave pode ler',
+ 'settings.apiScopes.hint':
+ 'Deixe tudo ligado para uma chave que deve enxergar tudo. O que você desligar é recusado para esta chave, não apenas omitido da resposta.',
+ 'settings.apiScopes.all': 'Tudo',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viagens',
+ 'settings.apiScopes.days': 'Dias',
+ 'settings.apiScopes.places': 'Lugares',
+ 'settings.apiScopes.notes': 'Notas do dia',
+ 'settings.apiScopes.reservations': 'Reservas',
+ 'settings.apiScopes.accommodations': 'Hospedagem',
+ 'settings.apiScopes.travellers': 'Quem vai',
+ 'settings.apiScopes.bucket-list': 'Lista de desejos',
+ 'settings.apiScopes.stats': 'Totais',
'settings.apiKeys.title': 'Chaves de API',
'settings.apiKeys.description': 'Chaves para a API pública, para que outros softwares possam ler suas viagens. Somente leitura: uma chave não pode alterar nem excluir nada.',
'settings.apiKeys.create': 'Criar chave',
diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts
index cc540096b..5dbce0c29 100644
--- a/shared/src/i18n/ca/admin.ts
+++ b/shared/src/i18n/ca/admin.ts
@@ -224,6 +224,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importa llocs des d’una llista compartida de Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sincronitza vols des de la teva instància d’AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Llegeix estades i recorreguts enregistrats d’una instància de Dawarich que cada lector connecta pel seu compte',
'admin.addons.catalog.llm_parsing.name': 'Anàlisi amb IA',
'admin.addons.catalog.llm_parsing.description': 'Llegeix reserves que l’analitzador integrat no entén, amb un model d’IA que triïs',
'admin.addons.enabled': 'Actiu',
diff --git a/shared/src/i18n/ca/dawarich.ts b/shared/src/i18n/ca/dawarich.ts
new file mode 100644
index 000000000..bbc9aa412
--- /dev/null
+++ b/shared/src/i18n/ca/dawarich.ts
@@ -0,0 +1,179 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Connecta la teva pròpia instància de Dawarich per veure on has estat realment. TREK la llegeix i et proposa entrades de diari, llocs i països — no s’hi afegeix res fins que ho confirmes, i no s’escriu res de tornada a Dawarich.',
+ 'dawarich.url': 'Adreça de la instància',
+ 'dawarich.apiKey': 'Clau API',
+ 'dawarich.apiKeyPlaceholder': 'Enganxa la teva clau API de Dawarich',
+ 'dawarich.apiKeyHint': 'La trobaràs a Dawarich sota Compte → Clau API. Es desa xifrada i no es torna a mostrar mai.',
+ 'dawarich.allowInsecureTls': 'Permet un certificat autosignat',
+ 'dawarich.allowInsecureTlsHint':
+ 'Només cal si la teva instància fa servir un certificat en què el teu servidor no confia.',
+ 'dawarich.syncEnabled': 'Comprova automàticament si hi ha estades noves',
+ 'dawarich.syncEnabledHint': 'Desactivat vol dir que TREK només llegeix Dawarich quan tu l’hi demanes.',
+ 'dawarich.test.button': 'Prova la connexió',
+ 'dawarich.test.success': 'Connectat. S’han trobat {count} estades en els darrers 30 dies.',
+ 'dawarich.test.failed': 'No s’ha pogut contactar amb Dawarich.',
+ 'dawarich.syncNow': 'Comprova-ho ara',
+ 'dawarich.connected': 'Connectat',
+ 'dawarich.notConnected': 'No connectat',
+ 'dawarich.disconnect': 'Desconnecta',
+ 'dawarich.lastSync': 'Darrera comprovació {when}',
+ 'dawarich.neverSynced': 'Encara no s’ha comprovat',
+ 'dawarich.syncPartial': 'alguns viatges no s’han pogut llegir',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'S’ha desat la connexió amb Dawarich',
+ 'dawarich.toast.saveError': 'No s’ha pogut desar la connexió',
+ 'dawarich.toast.disconnected': 'Dawarich desconnectat',
+ 'dawarich.toast.synced': 'S’han trobat {count} estades noves',
+ 'dawarich.toast.syncError': 'No s’ha pogut llegir Dawarich',
+ 'dawarich.toast.syncRunning': 'Ja hi ha una comprovació en curs',
+ 'dawarich.toast.acceptError': 'No s’ha pogut afegir això',
+ 'dawarich.toast.updateError': 'No s’ha pogut actualitzar aquest suggeriment',
+ 'dawarich.toast.accepted.place': 'Afegit al viatge',
+ 'dawarich.toast.accepted.journal': 'Afegit al diari',
+ 'dawarich.toast.accepted.bucket_list': 'Marcat a la teva llista de desitjos',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'estades',
+ 'dawarich.capability.track': 'recorregut enregistrat',
+ 'dawarich.capability.locations': 'coincidències amb la llista de desitjos',
+ 'dawarich.capability.visitedCities': 'països i ciutats',
+ 'dawarich.capability.missing': 'Aquesta versió de Dawarich no ofereix: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK no ha pogut contactar amb aquesta adreça.',
+ 'dawarich.error.unauthorized': 'Dawarich ha rebutjat la clau API.',
+ 'dawarich.error.forbidden': 'Aquesta clau API no té permís per llegir això.',
+ 'dawarich.error.not_found': 'Aquesta versió de Dawarich no té aquest punt final.',
+ 'dawarich.error.rate_limited': 'Dawarich ha demanat a TREK que vagi més a poc a poc. Torna-ho a provar d’aquí a poc.',
+ 'dawarich.error.server_error': 'Dawarich ha respost amb un error.',
+ 'dawarich.error.invalid_response': 'Aquesta adreça ha respost amb una cosa que no és Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich ha enviat més dades de les que TREK llegeix de cop.',
+ 'dawarich.error.not_connected': 'Encara no hi ha cap instància de Dawarich connectada.',
+ 'dawarich.error.addon_disabled': 'El complement Dawarich està desactivat en aquesta instància.',
+ 'dawarich.error.offline': 'Això necessita connexió: TREK ara és fora de línia.',
+ 'dawarich.error.invalid_url': 'TREK no pot fer servir aquesta adreça.',
+ 'dawarich.warning.private_ip': 'Aquesta adreça apunta a una IP privada ({ip}). Comprova que és el que volies: el servidor pot necessitar ALLOW_INTERNAL_NETWORK=true per arribar-hi.',
+ 'dawarich.error.unknown': 'Alguna cosa ha anat malament en parlar amb Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Mostra el recorregut enregistrat',
+ 'dawarich.trail.hide': 'Amaga el recorregut enregistrat',
+ 'dawarich.trail.loading': 'S’està carregant el recorregut enregistrat…',
+ 'dawarich.trail.empty': 'No s’ha enregistrat res en aquestes dates',
+ 'dawarich.trail.offline': 'El recorregut enregistrat necessita connexió',
+ 'dawarich.trail.unavailable': 'No s’ha pogut carregar el recorregut enregistrat',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'comprovat {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Incert',
+ 'dawarich.badge.sourceChanged': 'Canviat a Dawarich',
+ 'dawarich.badge.sourceMissing': 'Ja no hi és a Dawarich',
+
+ 'dawarich.suggestions.title': 'Des de Dawarich',
+ 'dawarich.suggestions.pending': '{count} pendents',
+ 'dawarich.suggestions.loading': 'S’està llegint Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Connecta Dawarich a la configuració per veure aquí les teves estades.',
+ 'dawarich.suggestions.unavailable': 'No s’ha pogut llegir Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Ja has resolt tot el que s’ha enregistrat aquí.',
+ 'dawarich.suggestions.asJournal': 'Escriu una entrada de diari',
+ 'dawarich.suggestions.asPlace': 'Afegeix-ho com a lloc',
+ 'dawarich.suggestions.dismiss': 'No és un lloc que hagi visitat',
+ 'dawarich.suggestions.dismissed': 'Descartat',
+ 'dawarich.suggestions.restore': 'Torna-ho a posar',
+ 'dawarich.suggestions.showHandled': 'Mostra’n {count} de ja resolts',
+ 'dawarich.suggestions.hideHandled': 'Amaga els que ja estan resolts',
+ 'dawarich.suggestions.matchesWish': 'A la teva llista de desitjos: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Afegit com a lloc',
+ 'dawarich.suggestions.acceptedAs.journal': 'Al diari',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Desig marcat',
+ 'dawarich.suggestions.sourceChanged':
+ 'Aquesta estada ha canviat a Dawarich des que la vas fer servir. El que has escrit a TREK no s’ha tocat.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Aquesta estada ja no existeix a Dawarich. El que has escrit a TREK no s’ha tocat.',
+ 'dawarich.sourceStatus.suggested': 'Detectat, sense confirmar',
+ 'dawarich.confidence.high': 'Detecció segura',
+ 'dawarich.confidence.medium': 'Detecció força segura',
+ 'dawarich.confidence.low': 'Detecció incerta',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Afegeix aquesta estada com a lloc',
+ 'dawarich.accept.title.journal': 'Escriu una entrada de diari',
+ 'dawarich.accept.title.bucket_list': 'Marca un desig',
+ 'dawarich.accept.confirm.place': 'Afegeix el lloc',
+ 'dawarich.accept.confirm.journal': 'Afegeix l’entrada',
+ 'dawarich.accept.confirm.bucket_list': 'Marca’l',
+ 'dawarich.accept.recorded': 'Enregistrat des de {from} fins a {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nom',
+ 'dawarich.accept.date': 'Data',
+ 'dawarich.accept.from': 'Arribada',
+ 'dawarich.accept.to': 'Sortida',
+ 'dawarich.accept.trip': 'Viatge',
+ 'dawarich.accept.thisTrip': 'Aquest viatge',
+ 'dawarich.accept.pickTrip': 'Tria un viatge',
+ 'dawarich.accept.day': 'Dia',
+ 'dawarich.accept.noDay': 'Encara no és en cap dia',
+ 'dawarich.accept.journal': 'Diari',
+ 'dawarich.accept.pickJournal': 'Tria un diari',
+ 'dawarich.accept.notes': 'Notes',
+ 'dawarich.accept.story': 'La teva història',
+ 'dawarich.accept.storyPlaceholder': 'Què va passar aquí?',
+ 'dawarich.accept.photosHint': 'Afegeix fotos a l’entrada un cop creada.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Afegit des dels teus registres de Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Comprova la teva llista de desitjos amb Dawarich',
+ 'dawarich.bucket.description':
+ 'Busca als teus enregistraments els llocs on volies arribar. Una visita necessita tant proximitat com temps d’estada — passar-hi de llarg no compta.',
+ 'dawarich.bucket.scan': 'Comprova la llista de desitjos',
+ 'dawarich.bucket.scanning': 'S’està comprovant…',
+ 'dawarich.bucket.noMatches': 'No ha aparegut res de la teva llista de desitjos als teus enregistraments.',
+ 'dawarich.bucket.alreadyVisited': 'Ja marcat',
+ 'dawarich.bucket.confirm': 'Marca’n {count}',
+ 'dawarich.bucket.confirmed': '{count} desitjos marcats',
+ 'dawarich.bucket.skipped': '{count} entrades no tenen coordenades i no s’han pogut comprovar.',
+ 'dawarich.bucket.truncated': 'Només s’han comprovat les primeres entrades. Torna-ho a executar per a la resta.',
+ 'dawarich.bucket.visitedFrom': 'Marcat a partir dels teus enregistraments de Dawarich',
+ 'dawarich.bucket.clearVisit': 'Desfés',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Països des de Dawarich',
+ 'dawarich.atlas.description':
+ 'Països on els teus enregistraments diuen que has estat. Confirma els que vulguis al teu Atles — no s’hi afegeix res tot sol, i el que has marcat a mà continua sent teu.',
+ 'dawarich.atlas.load': 'Busca països',
+ 'dawarich.atlas.loading': 'S’estan llegint els teus enregistraments…',
+ 'dawarich.atlas.empty': 'Els teus enregistraments no mostren cap país que TREK no tingui ja.',
+ 'dawarich.atlas.cities': '{count} ciutats',
+ 'dawarich.atlas.citiesOne': '1 ciutat',
+ 'dawarich.atlas.accept': 'Afegeix {count} països',
+ 'dawarich.atlas.accepted': '{count} països afegits',
+ 'dawarich.atlas.unresolved': 'TREK no ha pogut associar això a cap país: {names}.',
+ 'dawarich.atlas.source': 'Des de Dawarich',
+ 'dawarich.atlas.range': 'S’ha mirat de {from} a {to}',
+
+ 'dawarich.atlas.trigger': 'Desitjos i països dels teus registres',
+ 'dawarich.atlas.dialogSubtitle': 'Què diuen els teus registres sobre el teu Atles',
+ 'dawarich.atlas.tab.wishes': 'Llista de desitjos',
+ 'dawarich.atlas.tab.countries': 'Països',
+ 'dawarich.atlas.window': 'S’han revisat els darrers 12 mesos.',
+ 'dawarich.selected': '{count} seleccionats',
+ 'dawarich.again': 'Tornar a comprovar',
+ 'dawarich.bucket.metersAway': 'a {meters} m',
+ 'dawarich.bucket.kilometersAway': 'a {km} km',
+ 'dawarich.bucket.rule': 'Un desig es considera assolit a menys de {meters} m i després de {minutes} minuts al lloc.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts
index 3bbf7b802..1a9360160 100644
--- a/shared/src/i18n/ca/index.ts
+++ b/shared/src/i18n/ca/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts
index 1f7007457..d8620f1b0 100644
--- a/shared/src/i18n/ca/settings.ts
+++ b/shared/src/i18n/ca/settings.ts
@@ -555,6 +555,21 @@ const settings: TranslationStrings = {
'Dibuixa automàticament al mapa la ruta de cada vol, tren i altra reserva — no cal activar-la una per una.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Què pot llegir aquesta clau',
+ 'settings.apiScopes.hint':
+ 'Deixa-ho tot activat per a una clau que ho hagi de veure tot. El que desactivis es denega per a aquesta clau, no només es deixa fora de la resposta.',
+ 'settings.apiScopes.all': 'Tot',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viatges',
+ 'settings.apiScopes.days': 'Dies',
+ 'settings.apiScopes.places': 'Llocs',
+ 'settings.apiScopes.notes': 'Notes del dia',
+ 'settings.apiScopes.reservations': 'Reserves',
+ 'settings.apiScopes.accommodations': 'Allotjament',
+ 'settings.apiScopes.travellers': 'Qui hi va',
+ 'settings.apiScopes.bucket-list': 'Llista de desitjos',
+ 'settings.apiScopes.stats': 'Totals',
'settings.apiKeys.title': 'Claus API',
'settings.apiKeys.description': 'Claus per a l\'API pública, perquè altres programes puguin llegir els teus viatges. Només lectura: una clau no pot canviar ni esborrar res.',
'settings.apiKeys.create': 'Crea una clau',
diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts
index 7130a43fd..086cb047e 100644
--- a/shared/src/i18n/cs/admin.ts
+++ b/shared/src/i18n/cs/admin.ts
@@ -594,6 +594,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importujte místa ze sdíleného seznamu Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Synchronizujte lety ze své instance AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Načtěte návštěvy a zaznamenané trasy z instance Dawarich, kterou si každý čtenář připojí sám',
'admin.addons.catalog.llm_parsing.name': 'Analýza pomocí AI',
'admin.addons.catalog.llm_parsing.description': 'Přečte rezervace, které vestavěný analyzátor nezvládne, pomocí zvoleného modelu AI',
'admin.addons.catalog.roadtrip.name': 'Cesta autem',
diff --git a/shared/src/i18n/cs/dawarich.ts b/shared/src/i18n/cs/dawarich.ts
new file mode 100644
index 000000000..d8361290f
--- /dev/null
+++ b/shared/src/i18n/cs/dawarich.ts
@@ -0,0 +1,179 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Připojte svou vlastní instanci Dawarich a uvidíte, kde jste skutečně byli. TREK ji přečte a navrhne záznamy do deníku, místa a země — nic se nepřidá, dokud to nepotvrdíte, a do Dawarichu se nic nezapisuje.',
+ 'dawarich.url': 'Adresa instance',
+ 'dawarich.apiKey': 'API klíč',
+ 'dawarich.apiKeyPlaceholder': 'Vložte svůj API klíč Dawarich',
+ 'dawarich.apiKeyHint': 'Najdete jej v Dawarichu pod Účet → API klíč. Ukládá se zašifrovaně a znovu se nezobrazí.',
+ 'dawarich.allowInsecureTls': 'Povolit vlastnoručně podepsaný certifikát',
+ 'dawarich.allowInsecureTlsHint':
+ 'Potřebné jen tehdy, když vaše instance používá certifikát, kterému váš server nedůvěřuje.',
+ 'dawarich.syncEnabled': 'Automaticky hledat nové pobyty',
+ 'dawarich.syncEnabledHint': 'Když je vypnuto, TREK čte Dawarich jen na vyžádání.',
+ 'dawarich.test.button': 'Otestovat připojení',
+ 'dawarich.test.success': 'Připojeno. Za posledních 30 dní nalezeno {count} pobytů.',
+ 'dawarich.test.failed': 'Dawarich se nepodařilo kontaktovat.',
+ 'dawarich.syncNow': 'Zkontrolovat teď',
+ 'dawarich.connected': 'Připojeno',
+ 'dawarich.notConnected': 'Nepřipojeno',
+ 'dawarich.disconnect': 'Odpojit',
+ 'dawarich.lastSync': 'Naposledy zkontrolováno {when}',
+ 'dawarich.neverSynced': 'Zatím nezkontrolováno',
+ 'dawarich.syncPartial': 'některé cesty se nepodařilo načíst',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Připojení k Dawarichu uloženo',
+ 'dawarich.toast.saveError': 'Připojení se nepodařilo uložit',
+ 'dawarich.toast.disconnected': 'Dawarich odpojen',
+ 'dawarich.toast.synced': 'Nalezeno {count} nových pobytů',
+ 'dawarich.toast.syncError': 'Dawarich se nepodařilo načíst',
+ 'dawarich.toast.syncRunning': 'Kontrola už probíhá',
+ 'dawarich.toast.acceptError': 'Toto se nepodařilo přidat',
+ 'dawarich.toast.updateError': 'Tento návrh se nepodařilo aktualizovat',
+ 'dawarich.toast.accepted.place': 'Přidáno do cesty',
+ 'dawarich.toast.accepted.journal': 'Přidáno do deníku',
+ 'dawarich.toast.accepted.bucket_list': 'Odškrtnuto ze seznamu přání',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'pobyty',
+ 'dawarich.capability.track': 'zaznamenaná trasa',
+ 'dawarich.capability.locations': 'porovnání se seznamem přání',
+ 'dawarich.capability.visitedCities': 'země a města',
+ 'dawarich.capability.missing': 'Tato verze Dawarichu nenabízí: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK se na tuto adresu nedostal.',
+ 'dawarich.error.unauthorized': 'Dawarich odmítl API klíč.',
+ 'dawarich.error.forbidden': 'Tento API klíč nemá oprávnění toto číst.',
+ 'dawarich.error.not_found': 'Tato verze Dawarichu tento koncový bod nemá.',
+ 'dawarich.error.rate_limited': 'Dawarich požádal TREK o zpomalení. Zkuste to prosím za chvíli znovu.',
+ 'dawarich.error.server_error': 'Dawarich odpověděl chybou.',
+ 'dawarich.error.invalid_response': 'Tato adresa odpověděla něčím, co není Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich poslal víc dat, než kolik TREK přečte najednou.',
+ 'dawarich.error.not_connected': 'Zatím není připojena žádná instance Dawarich.',
+ 'dawarich.error.addon_disabled': 'Doplněk Dawarich je v této instanci vypnutý.',
+ 'dawarich.error.offline': 'To vyžaduje připojení — TREK je právě offline.',
+ 'dawarich.error.invalid_url': 'TREK tuto adresu použít nemůže.',
+ 'dawarich.warning.private_ip': 'Tato adresa míří na privátní IP ({ip}). Ověřte, že to tak má být — server k ní možná potřebuje ALLOW_INTERNAL_NETWORK=true.',
+ 'dawarich.error.unknown': 'Při komunikaci s Dawarichem se něco pokazilo.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Zobrazit zaznamenanou trasu',
+ 'dawarich.trail.hide': 'Skrýt zaznamenanou trasu',
+ 'dawarich.trail.loading': 'Načítá se zaznamenaná trasa…',
+ 'dawarich.trail.empty': 'V těchto dnech nebylo nic zaznamenáno',
+ 'dawarich.trail.offline': 'Zaznamenaná trasa vyžaduje připojení',
+ 'dawarich.trail.unavailable': 'Zaznamenanou trasu se nepodařilo načíst',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'zkontrolováno {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Nejisté',
+ 'dawarich.badge.sourceChanged': 'Změněno v Dawarichu',
+ 'dawarich.badge.sourceMissing': 'V Dawarichu už není',
+
+ 'dawarich.suggestions.title': 'Z Dawarichu',
+ 'dawarich.suggestions.pending': '{count} čeká na vás',
+ 'dawarich.suggestions.loading': 'Načítá se Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Připojte Dawarich v Nastavení a uvidíte tu své pobyty.',
+ 'dawarich.suggestions.unavailable': 'Dawarich se nepodařilo načíst.',
+ 'dawarich.suggestions.allHandled': 'Všechno, co je tu zaznamenáno, už je vyřízené.',
+ 'dawarich.suggestions.asJournal': 'Napsat záznam do deníku',
+ 'dawarich.suggestions.asPlace': 'Přidat jako místo',
+ 'dawarich.suggestions.dismiss': 'Tady jsem nebyl',
+ 'dawarich.suggestions.dismissed': 'Zamítnuto',
+ 'dawarich.suggestions.restore': 'Vrátit zpět',
+ 'dawarich.suggestions.showHandled': 'Zobrazit {count} již vyřízených',
+ 'dawarich.suggestions.hideHandled': 'Skrýt už vyřízené',
+ 'dawarich.suggestions.matchesWish': 'Na vašem seznamu přání: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Přidáno jako místo',
+ 'dawarich.suggestions.acceptedAs.journal': 'V deníku',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Přání odškrtnuto',
+ 'dawarich.suggestions.sourceChanged':
+ 'Tento pobyt se od vašeho použití v Dawarichu změnil. To, co jste napsali v TREKu, zůstává beze změny.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Tento pobyt už v Dawarichu neexistuje. To, co jste napsali v TREKu, zůstává beze změny.',
+ 'dawarich.sourceStatus.suggested': 'Rozpoznáno, nepotvrzeno',
+ 'dawarich.confidence.high': 'Jisté rozpoznání',
+ 'dawarich.confidence.medium': 'Poměrně jisté rozpoznání',
+ 'dawarich.confidence.low': 'Nejisté rozpoznání',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Přidat tento pobyt jako místo',
+ 'dawarich.accept.title.journal': 'Napsat záznam do deníku',
+ 'dawarich.accept.title.bucket_list': 'Odškrtnout přání',
+ 'dawarich.accept.confirm.place': 'Přidat místo',
+ 'dawarich.accept.confirm.journal': 'Přidat záznam',
+ 'dawarich.accept.confirm.bucket_list': 'Odškrtnout',
+ 'dawarich.accept.recorded': 'Zaznamenáno od {from} do {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Název',
+ 'dawarich.accept.date': 'Datum',
+ 'dawarich.accept.from': 'Příchod',
+ 'dawarich.accept.to': 'Odchod',
+ 'dawarich.accept.trip': 'Cesta',
+ 'dawarich.accept.thisTrip': 'Tato cesta',
+ 'dawarich.accept.pickTrip': 'Vyberte cestu',
+ 'dawarich.accept.day': 'Den',
+ 'dawarich.accept.noDay': 'Zatím bez dne',
+ 'dawarich.accept.journal': 'Deník',
+ 'dawarich.accept.pickJournal': 'Vyberte deník',
+ 'dawarich.accept.notes': 'Poznámky',
+ 'dawarich.accept.story': 'Váš příběh',
+ 'dawarich.accept.storyPlaceholder': 'Co se tu stalo?',
+ 'dawarich.accept.photosHint': 'Fotky přidáte k záznamu až po jeho vytvoření.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Přidáno z vašich záznamů Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Porovnat seznam přání s Dawarichem',
+ 'dawarich.bucket.description':
+ 'Prohledá vaše záznamy a hledá místa, kam jste se chtěli dostat. Návštěva vyžaduje blízkost i strávený čas — projet kolem se nepočítá.',
+ 'dawarich.bucket.scan': 'Zkontrolovat seznam přání',
+ 'dawarich.bucket.scanning': 'Kontroluje se…',
+ 'dawarich.bucket.noMatches': 'Ve vašich záznamech se neobjevilo nic z vašeho seznamu přání.',
+ 'dawarich.bucket.alreadyVisited': 'Již odškrtnuto',
+ 'dawarich.bucket.confirm': 'Odškrtnout {count}',
+ 'dawarich.bucket.confirmed': 'Odškrtnuto přání: {count}',
+ 'dawarich.bucket.skipped': '{count} položek nemá souřadnice a nešlo je zkontrolovat.',
+ 'dawarich.bucket.truncated': 'Zkontrolovány byly jen první položky. Zbytek zkontrolujete dalším spuštěním.',
+ 'dawarich.bucket.visitedFrom': 'Odškrtnuto podle vašich záznamů v Dawarichu',
+ 'dawarich.bucket.clearVisit': 'Vrátit zpět',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Země z Dawarichu',
+ 'dawarich.atlas.description':
+ 'Země, ve kterých jste podle svých záznamů byli. Potvrďte ty, které chcete mít v Atlasu — nic se nepřidá samo a co jste označili ručně, zůstává vaše.',
+ 'dawarich.atlas.load': 'Vyhledat země',
+ 'dawarich.atlas.loading': 'Načítají se vaše záznamy…',
+ 'dawarich.atlas.empty': 'Vaše záznamy neukazují žádné země, které by TREK ještě neměl.',
+ 'dawarich.atlas.cities': 'Měst: {count}',
+ 'dawarich.atlas.citiesOne': '1 město',
+ 'dawarich.atlas.accept': 'Přidat země: {count}',
+ 'dawarich.atlas.accepted': 'Přidáno zemí: {count}',
+ 'dawarich.atlas.unresolved': 'TREK nedokázal tyto přiřadit k zemi: {names}.',
+ 'dawarich.atlas.source': 'Z Dawarichu',
+ 'dawarich.atlas.range': 'Prohlédnuto od {from} do {to}',
+
+ 'dawarich.atlas.trigger': 'Přání a země z vašich záznamů',
+ 'dawarich.atlas.dialogSubtitle': 'Co vaše záznamy říkají o vašem Atlasu',
+ 'dawarich.atlas.tab.wishes': 'Seznam přání',
+ 'dawarich.atlas.tab.countries': 'Země',
+ 'dawarich.atlas.window': 'Prohledáno bylo posledních 12 měsíců.',
+ 'dawarich.selected': 'Vybráno: {count}',
+ 'dawarich.again': 'Zkontrolovat znovu',
+ 'dawarich.bucket.metersAway': '{meters} m daleko',
+ 'dawarich.bucket.kilometersAway': '{km} km daleko',
+ 'dawarich.bucket.rule': 'Přání se počítá jako splněné do {meters} m a po {minutes} minutách na místě.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/cs/index.ts
+++ b/shared/src/i18n/cs/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts
index 099c66256..12da1ee09 100644
--- a/shared/src/i18n/cs/settings.ts
+++ b/shared/src/i18n/cs/settings.ts
@@ -544,6 +544,21 @@ const settings: TranslationStrings = {
'Automaticky zobrazí na mapě trasu každého letu, vlaku a jiné rezervace, aniž byste ji museli zapínat jednotlivě.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Co smí tento klíč číst',
+ 'settings.apiScopes.hint':
+ 'Pro klíč, který má vidět všechno, nechte vše zapnuté. Cokoli vypnete, bude tomuto klíči odepřeno, nejen vynecháno z odpovědi.',
+ 'settings.apiScopes.all': 'Vše',
+ 'settings.apiScopes.limited': '{count} z {total}',
+ 'settings.apiScopes.trips': 'Cesty',
+ 'settings.apiScopes.days': 'Dny',
+ 'settings.apiScopes.places': 'Místa',
+ 'settings.apiScopes.notes': 'Poznámky ke dni',
+ 'settings.apiScopes.reservations': 'Rezervace',
+ 'settings.apiScopes.accommodations': 'Ubytování',
+ 'settings.apiScopes.travellers': 'Kdo jede',
+ 'settings.apiScopes.bucket-list': 'Seznam přání',
+ 'settings.apiScopes.stats': 'Souhrny',
'settings.apiKeys.title': 'Klíče API',
'settings.apiKeys.description': 'Klíče pro veřejné API, aby jiný software mohl číst tvoje cesty. Jen pro čtení: klíč nemůže nic měnit ani mazat.',
'settings.apiKeys.create': 'Vytvořit klíč',
diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts
index d980385e4..f58d44fd8 100644
--- a/shared/src/i18n/de/admin.ts
+++ b/shared/src/i18n/de/admin.ts
@@ -611,6 +611,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Orte aus einer geteilten Naver-Maps-Liste importieren',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Flüge aus deiner AirTrail-Instanz synchronisieren',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Aufenthalte und aufgezeichnete Routen aus einer Dawarich-Instanz lesen, die jeder selbst verbindet',
'admin.addons.catalog.llm_parsing.name': 'KI-Auswertung',
'admin.addons.catalog.llm_parsing.description': 'Liest Buchungen, an denen der eingebaute Parser scheitert, mit einem KI-Modell deiner Wahl',
'admin.addons.enabled': 'Aktiviert',
diff --git a/shared/src/i18n/de/dawarich.ts b/shared/src/i18n/de/dawarich.ts
new file mode 100644
index 000000000..78283b868
--- /dev/null
+++ b/shared/src/i18n/de/dawarich.ts
@@ -0,0 +1,183 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Verbinde deine eigene Dawarich-Instanz, um zu sehen, wo du tatsächlich warst. TREK liest sie aus und schlägt Tagebucheinträge, Orte und Länder vor — nichts wird übernommen, bevor du es bestätigst, und nach Dawarich wird nichts zurückgeschrieben.',
+ 'dawarich.url': 'Adresse der Instanz',
+ 'dawarich.apiKey': 'API-Schlüssel',
+ 'dawarich.apiKeyPlaceholder': 'Deinen Dawarich-API-Schlüssel einfügen',
+ 'dawarich.apiKeyHint':
+ 'Zu finden in Dawarich unter Account → API key. Wird verschlüsselt gespeichert und nie wieder angezeigt.',
+ 'dawarich.allowInsecureTls': 'Selbstsigniertes Zertifikat zulassen',
+ 'dawarich.allowInsecureTlsHint':
+ 'Nur nötig, wenn deine Instanz ein Zertifikat nutzt, dem dein Server nicht vertraut.',
+ 'dawarich.syncEnabled': 'Automatisch nach neuen Aufenthalten suchen',
+ 'dawarich.syncEnabledHint': 'Aus bedeutet: TREK liest Dawarich nur, wenn du es anstößt.',
+ 'dawarich.test.button': 'Verbindung testen',
+ 'dawarich.test.success': 'Verbunden. {count} Aufenthalte in den letzten 30 Tagen gefunden.',
+ 'dawarich.test.failed': 'Dawarich war nicht erreichbar.',
+ 'dawarich.syncNow': 'Jetzt nachsehen',
+ 'dawarich.connected': 'Verbunden',
+ 'dawarich.notConnected': 'Nicht verbunden',
+ 'dawarich.disconnect': 'Trennen',
+ 'dawarich.lastSync': 'Zuletzt nachgesehen {when}',
+ 'dawarich.neverSynced': 'Noch nicht nachgesehen',
+ 'dawarich.syncPartial': 'einige Reisen konnten nicht gelesen werden',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich-Verbindung gespeichert',
+ 'dawarich.toast.saveError': 'Die Verbindung konnte nicht gespeichert werden',
+ 'dawarich.toast.disconnected': 'Dawarich getrennt',
+ 'dawarich.toast.synced': '{count} neue Aufenthalte gefunden',
+ 'dawarich.toast.syncError': 'Dawarich konnte nicht gelesen werden',
+ 'dawarich.toast.syncRunning': 'Es läuft bereits eine Prüfung',
+ 'dawarich.toast.acceptError': 'Das konnte nicht übernommen werden',
+ 'dawarich.toast.updateError': 'Dieser Vorschlag konnte nicht aktualisiert werden',
+ 'dawarich.toast.accepted.place': 'Zur Reise hinzugefügt',
+ 'dawarich.toast.accepted.journal': 'Ins Tagebuch übernommen',
+ 'dawarich.toast.accepted.bucket_list': 'Von deiner Wunschliste abgehakt',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'Aufenthalte',
+ 'dawarich.capability.track': 'aufgezeichnete Route',
+ 'dawarich.capability.locations': 'Abgleich mit der Wunschliste',
+ 'dawarich.capability.visitedCities': 'Länder und Städte',
+ 'dawarich.capability.missing': 'Diese Dawarich-Version bietet nicht: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK konnte diese Adresse nicht erreichen.',
+ 'dawarich.error.unauthorized': 'Dawarich hat den API-Schlüssel abgelehnt.',
+ 'dawarich.error.forbidden': 'Dieser API-Schlüssel darf das nicht lesen.',
+ 'dawarich.error.not_found': 'Diese Dawarich-Version hat diesen Endpunkt nicht.',
+ 'dawarich.error.rate_limited':
+ 'Dawarich hat TREK gebeten, langsamer zu machen. Versuch es gleich noch einmal.',
+ 'dawarich.error.server_error': 'Dawarich hat mit einem Fehler geantwortet.',
+ 'dawarich.error.invalid_response': 'Diese Adresse hat mit etwas geantwortet, das kein Dawarich ist.',
+ 'dawarich.error.too_large': 'Dawarich hat mehr Daten geschickt, als TREK auf einmal liest.',
+ 'dawarich.error.not_connected': 'Es ist noch keine Dawarich-Instanz verbunden.',
+ 'dawarich.error.addon_disabled': 'Das Dawarich-Addon ist für diese Instanz abgeschaltet.',
+ 'dawarich.error.offline': 'Dafür braucht es eine Verbindung — TREK ist gerade offline.',
+ 'dawarich.error.invalid_url': 'TREK kann diese Adresse nicht verwenden.',
+ 'dawarich.warning.private_ip': 'Diese Adresse zeigt auf eine private IP ({ip}). Prüfe, ob das so gewollt ist — der Server braucht dafür eventuell ALLOW_INTERNAL_NETWORK=true.',
+ 'dawarich.error.unknown': 'Bei der Kommunikation mit Dawarich ist etwas schiefgelaufen.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Aufgezeichnete Route anzeigen',
+ 'dawarich.trail.hide': 'Aufgezeichnete Route ausblenden',
+ 'dawarich.trail.loading': 'Aufgezeichnete Route wird geladen…',
+ 'dawarich.trail.empty': 'An diesen Tagen wurde nichts aufgezeichnet',
+ 'dawarich.trail.offline': 'Die aufgezeichnete Route braucht eine Verbindung',
+ 'dawarich.trail.unavailable': 'Die aufgezeichnete Route konnte nicht geladen werden',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} Min.',
+ 'dawarich.duration.hours': '{hours} Std.',
+ 'dawarich.duration.hoursMinutes': '{hours} Std. {minutes} Min.',
+ 'dawarich.checkedAgo': 'geprüft {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Unsicher',
+ 'dawarich.badge.sourceChanged': 'In Dawarich geändert',
+ 'dawarich.badge.sourceMissing': 'In Dawarich nicht mehr da',
+
+ 'dawarich.suggestions.title': 'Aus Dawarich',
+ 'dawarich.suggestions.pending': '{count} warten auf dich',
+ 'dawarich.suggestions.loading': 'Dawarich wird gelesen…',
+ 'dawarich.suggestions.notConnected':
+ 'Verbinde Dawarich in den Einstellungen, um deine Aufenthalte hier zu sehen.',
+ 'dawarich.suggestions.unavailable': 'Dawarich konnte nicht gelesen werden.',
+ 'dawarich.suggestions.allHandled': 'Alles, was hier aufgezeichnet wurde, ist erledigt.',
+ 'dawarich.suggestions.asJournal': 'Tagebucheintrag schreiben',
+ 'dawarich.suggestions.asPlace': 'Als Ort hinzufügen',
+ 'dawarich.suggestions.dismiss': 'Da war ich nicht',
+ 'dawarich.suggestions.dismissed': 'Verworfen',
+ 'dawarich.suggestions.restore': 'Zurückholen',
+ 'dawarich.suggestions.showHandled': '{count} bereits erledigte anzeigen',
+ 'dawarich.suggestions.hideHandled': 'Bereits erledigte ausblenden',
+ 'dawarich.suggestions.matchesWish': 'Auf deiner Wunschliste: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Als Ort hinzugefügt',
+ 'dawarich.suggestions.acceptedAs.journal': 'Im Tagebuch',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Wunsch abgehakt',
+ 'dawarich.suggestions.sourceChanged':
+ 'Dieser Aufenthalt hat sich in Dawarich geändert, seit du ihn übernommen hast. Was du in TREK geschrieben hast, bleibt unberührt.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Diesen Aufenthalt gibt es in Dawarich nicht mehr. Was du in TREK geschrieben hast, bleibt unberührt.',
+ 'dawarich.sourceStatus.suggested': 'Erkannt, nicht bestätigt',
+ 'dawarich.confidence.high': 'Sichere Erkennung',
+ 'dawarich.confidence.medium': 'Recht sichere Erkennung',
+ 'dawarich.confidence.low': 'Unsichere Erkennung',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Diesen Aufenthalt als Ort hinzufügen',
+ 'dawarich.accept.title.journal': 'Tagebucheintrag schreiben',
+ 'dawarich.accept.title.bucket_list': 'Einen Wunsch abhaken',
+ 'dawarich.accept.confirm.place': 'Ort hinzufügen',
+ 'dawarich.accept.confirm.journal': 'Eintrag hinzufügen',
+ 'dawarich.accept.confirm.bucket_list': 'Abhaken',
+ 'dawarich.accept.recorded': 'Aufgezeichnet {from} bis {to}',
+ 'dawarich.accept.duration': '{minutes} Min.',
+ 'dawarich.accept.name': 'Name',
+ 'dawarich.accept.date': 'Datum',
+ 'dawarich.accept.from': 'Angekommen',
+ 'dawarich.accept.to': 'Aufgebrochen',
+ 'dawarich.accept.trip': 'Reise',
+ 'dawarich.accept.thisTrip': 'Diese Reise',
+ 'dawarich.accept.pickTrip': 'Reise auswählen',
+ 'dawarich.accept.day': 'Tag',
+ 'dawarich.accept.noDay': 'Noch keinem Tag zugeordnet',
+ 'dawarich.accept.journal': 'Tagebuch',
+ 'dawarich.accept.pickJournal': 'Tagebuch auswählen',
+ 'dawarich.accept.notes': 'Notizen',
+ 'dawarich.accept.story': 'Deine Geschichte',
+ 'dawarich.accept.storyPlaceholder': 'Was ist hier passiert?',
+ 'dawarich.accept.photosHint': 'Fotos kannst du dem Eintrag hinzufügen, sobald er angelegt ist.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Aus deinen Dawarich-Aufzeichnungen übernommen',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Wunschliste mit Dawarich abgleichen',
+ 'dawarich.bucket.description':
+ 'Durchsucht deine Aufzeichnungen nach den Orten, die du erreichen wolltest. Ein Besuch braucht Nähe und verbrachte Zeit — Vorbeifahren zählt nicht.',
+ 'dawarich.bucket.scan': 'Wunschliste prüfen',
+ 'dawarich.bucket.scanning': 'Wird geprüft…',
+ 'dawarich.bucket.noMatches': 'Nichts von deiner Wunschliste taucht in deinen Aufzeichnungen auf.',
+ 'dawarich.bucket.alreadyVisited': 'Bereits abgehakt',
+ 'dawarich.bucket.confirm': '{count} abhaken',
+ 'dawarich.bucket.confirmed': '{count} Wünsche abgehakt',
+ 'dawarich.bucket.skipped': '{count} Einträge haben keine Koordinaten und konnten nicht geprüft werden.',
+ 'dawarich.bucket.truncated':
+ 'Es wurden nur die ersten Einträge geprüft. Starte den Abgleich für den Rest noch einmal.',
+ 'dawarich.bucket.visitedFrom': 'Anhand deiner Dawarich-Aufzeichnungen abgehakt',
+ 'dawarich.bucket.clearVisit': 'Rückgängig',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Länder aus Dawarich',
+ 'dawarich.atlas.description':
+ 'Länder, in denen du deinen Aufzeichnungen zufolge warst. Bestätige die, die in deinen Atlas sollen — nichts wird von allein übernommen, und was du von Hand markiert hast, bleibt deins.',
+ 'dawarich.atlas.load': 'Nach Ländern suchen',
+ 'dawarich.atlas.loading': 'Deine Aufzeichnungen werden gelesen…',
+ 'dawarich.atlas.empty': 'Deine Aufzeichnungen zeigen keine Länder, die TREK nicht schon hat.',
+ 'dawarich.atlas.cities': '{count} Städte',
+ 'dawarich.atlas.citiesOne': '1 Stadt',
+ 'dawarich.atlas.accept': '{count} Länder hinzufügen',
+ 'dawarich.atlas.accepted': '{count} Länder hinzugefügt',
+ 'dawarich.atlas.unresolved': 'TREK konnte diese keinem Land zuordnen: {names}.',
+ 'dawarich.atlas.source': 'Aus Dawarich',
+ 'dawarich.atlas.range': 'Betrachtet: {from} bis {to}',
+
+ 'dawarich.atlas.trigger': 'Wünsche und Länder aus deinen Aufzeichnungen',
+ 'dawarich.atlas.dialogSubtitle': 'Was deine Aufzeichnungen über deinen Atlas sagen',
+ 'dawarich.atlas.tab.wishes': 'Wunschliste',
+ 'dawarich.atlas.tab.countries': 'Länder',
+ 'dawarich.atlas.window': 'Angesehen wurden die letzten 12 Monate.',
+ 'dawarich.selected': '{count} ausgewählt',
+ 'dawarich.again': 'Erneut prüfen',
+ 'dawarich.bucket.metersAway': '{meters} m entfernt',
+ 'dawarich.bucket.kilometersAway': '{km} km entfernt',
+ 'dawarich.bucket.rule': 'Ein Wunsch gilt ab {meters} m Nähe und {minutes} Minuten vor Ort als erreicht.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/de/index.ts
+++ b/shared/src/i18n/de/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts
index d9e10d945..454e407e5 100644
--- a/shared/src/i18n/de/settings.ts
+++ b/shared/src/i18n/de/settings.ts
@@ -555,6 +555,21 @@ const settings: TranslationStrings = {
'Zeigt die Route für jeden Flug, jede Zugfahrt und jede andere Buchung automatisch auf der Karte an – ohne sie einzeln aktivieren zu müssen.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Was dieser Schlüssel lesen darf',
+ 'settings.apiScopes.hint':
+ 'Lass alles an, wenn der Schlüssel alles sehen soll. Was du abschaltest, wird für diesen Schlüssel abgelehnt und nicht nur aus der Antwort weggelassen.',
+ 'settings.apiScopes.all': 'Alles',
+ 'settings.apiScopes.limited': '{count} von {total}',
+ 'settings.apiScopes.trips': 'Reisen',
+ 'settings.apiScopes.days': 'Tage',
+ 'settings.apiScopes.places': 'Orte',
+ 'settings.apiScopes.notes': 'Tagesnotizen',
+ 'settings.apiScopes.reservations': 'Buchungen',
+ 'settings.apiScopes.accommodations': 'Unterkunft',
+ 'settings.apiScopes.travellers': 'Wer mitkommt',
+ 'settings.apiScopes.bucket-list': 'Wunschliste',
+ 'settings.apiScopes.stats': 'Summen',
'settings.apiKeys.title': 'API-Schlüssel',
'settings.apiKeys.description': 'Schlüssel für die öffentliche API, damit andere Software deine Reisen lesen kann. Nur lesend: ein Schlüssel kann nichts ändern oder löschen.',
'settings.apiKeys.create': 'Schlüssel erstellen',
diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts
index 72e74f575..abf876a4f 100644
--- a/shared/src/i18n/en/admin.ts
+++ b/shared/src/i18n/en/admin.ts
@@ -647,6 +647,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Import places from a shared Naver Maps list',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sync flights from your AirTrail instance',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Read visits and recorded routes from a Dawarich instance each reader connects themselves',
'admin.addons.catalog.llm_parsing.name': 'AI Parsing',
'admin.addons.catalog.llm_parsing.description': 'Reads bookings the built-in parser cannot, using an AI model you choose',
'admin.addons.enabled': 'Enabled',
diff --git a/shared/src/i18n/en/dawarich.ts b/shared/src/i18n/en/dawarich.ts
new file mode 100644
index 000000000..a04011cee
--- /dev/null
+++ b/shared/src/i18n/en/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Connect your own Dawarich instance to see where you actually went. TREK reads it and suggests journal entries, places and countries — nothing is added until you confirm it, and nothing is written back to Dawarich.',
+ 'dawarich.url': 'Instance address',
+ 'dawarich.apiKey': 'API key',
+ 'dawarich.apiKeyPlaceholder': 'Paste your Dawarich API key',
+ 'dawarich.apiKeyHint': 'Found in Dawarich under Account → API key. Stored encrypted and never shown again.',
+ 'dawarich.allowInsecureTls': 'Allow self-signed certificate',
+ 'dawarich.allowInsecureTlsHint': 'Only needed if your instance uses a certificate your server does not trust.',
+ 'dawarich.syncEnabled': 'Check for new stays automatically',
+ 'dawarich.syncEnabledHint': 'Off means TREK only reads Dawarich when you ask it to.',
+ 'dawarich.test.button': 'Test connection',
+ 'dawarich.test.success': 'Connected. {count} stays found in the last 30 days.',
+ 'dawarich.test.failed': 'Could not reach Dawarich.',
+ 'dawarich.syncNow': 'Check now',
+ 'dawarich.connected': 'Connected',
+ 'dawarich.notConnected': 'Not connected',
+ 'dawarich.disconnect': 'Disconnect',
+ 'dawarich.lastSync': 'Last checked {when}',
+ 'dawarich.neverSynced': 'Not checked yet',
+ 'dawarich.syncPartial': 'some trips could not be read',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich connection saved',
+ 'dawarich.toast.saveError': 'Could not save the connection',
+ 'dawarich.toast.disconnected': 'Dawarich disconnected',
+ 'dawarich.toast.synced': '{count} new stays found',
+ 'dawarich.toast.syncError': 'Could not read Dawarich',
+ 'dawarich.toast.syncRunning': 'A check is already running',
+ 'dawarich.toast.acceptError': 'Could not add this',
+ 'dawarich.toast.updateError': 'Could not update this suggestion',
+ 'dawarich.toast.accepted.place': 'Added to the trip',
+ 'dawarich.toast.accepted.journal': 'Added to the journal',
+ 'dawarich.toast.accepted.bucket_list': 'Ticked off your wishlist',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'stays',
+ 'dawarich.capability.track': 'recorded route',
+ 'dawarich.capability.locations': 'wishlist matching',
+ 'dawarich.capability.visitedCities': 'countries and cities',
+ 'dawarich.capability.missing': 'This Dawarich version does not offer: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK could not reach that address.',
+ 'dawarich.error.unauthorized': 'Dawarich rejected the API key.',
+ 'dawarich.error.forbidden': 'That API key is not allowed to read this.',
+ 'dawarich.error.not_found': 'This Dawarich version does not have that endpoint.',
+ 'dawarich.error.rate_limited': 'Dawarich asked TREK to slow down. Try again shortly.',
+ 'dawarich.error.server_error': 'Dawarich answered with an error.',
+ 'dawarich.error.invalid_response': 'That address answered with something that is not Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich sent more data than TREK will read at once.',
+ 'dawarich.error.not_connected': 'No Dawarich instance is connected yet.',
+ 'dawarich.error.addon_disabled': 'The Dawarich addon is switched off for this instance.',
+ 'dawarich.error.offline': 'This needs a connection — TREK is offline right now.',
+ 'dawarich.error.invalid_url': 'TREK cannot use that address.',
+ 'dawarich.warning.private_ip': 'That address resolves to a private IP ({ip}). Make sure that is what you meant — the server may need ALLOW_INTERNAL_NETWORK=true to reach it.',
+ 'dawarich.error.unknown': 'Something went wrong talking to Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Show recorded route',
+ 'dawarich.trail.hide': 'Hide recorded route',
+ 'dawarich.trail.loading': 'Loading the recorded route…',
+ 'dawarich.trail.empty': 'Nothing was recorded on these dates',
+ 'dawarich.trail.offline': 'The recorded route needs a connection',
+ 'dawarich.trail.unavailable': 'The recorded route could not be loaded',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'checked {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Unsure',
+ 'dawarich.badge.sourceChanged': 'Changed in Dawarich',
+ 'dawarich.badge.sourceMissing': 'Gone from Dawarich',
+
+ 'dawarich.suggestions.title': 'From Dawarich',
+ 'dawarich.suggestions.pending': '{count} waiting for you',
+ 'dawarich.suggestions.loading': 'Reading Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Connect Dawarich in Settings to see your stays here.',
+ 'dawarich.suggestions.unavailable': 'Dawarich could not be read.',
+ 'dawarich.suggestions.allHandled': 'Everything recorded here has been dealt with.',
+ 'dawarich.suggestions.asJournal': 'Write a journal entry',
+ 'dawarich.suggestions.asPlace': 'Add as a place',
+ 'dawarich.suggestions.dismiss': 'Not a place I visited',
+ 'dawarich.suggestions.dismissed': 'Dismissed',
+ 'dawarich.suggestions.restore': 'Put back',
+ 'dawarich.suggestions.showHandled': 'Show {count} already dealt with',
+ 'dawarich.suggestions.hideHandled': 'Hide the ones already dealt with',
+ 'dawarich.suggestions.matchesWish': 'On your wishlist: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Added as a place',
+ 'dawarich.suggestions.acceptedAs.journal': 'In the journal',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Wish ticked off',
+ 'dawarich.suggestions.sourceChanged':
+ 'This stay has changed in Dawarich since you used it. What you wrote in TREK is untouched.',
+ 'dawarich.suggestions.sourceMissing':
+ 'This stay no longer exists in Dawarich. What you wrote in TREK is untouched.',
+ 'dawarich.sourceStatus.suggested': 'Detected, unconfirmed',
+ 'dawarich.confidence.high': 'Confident detection',
+ 'dawarich.confidence.medium': 'Fairly confident detection',
+ 'dawarich.confidence.low': 'Uncertain detection',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Add this stay as a place',
+ 'dawarich.accept.title.journal': 'Write a journal entry',
+ 'dawarich.accept.title.bucket_list': 'Tick off a wish',
+ 'dawarich.accept.confirm.place': 'Add place',
+ 'dawarich.accept.confirm.journal': 'Add entry',
+ 'dawarich.accept.confirm.bucket_list': 'Tick it off',
+ 'dawarich.accept.recorded': 'Recorded {from} until {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Name',
+ 'dawarich.accept.date': 'Date',
+ 'dawarich.accept.from': 'Arrived',
+ 'dawarich.accept.to': 'Left',
+ 'dawarich.accept.trip': 'Trip',
+ 'dawarich.accept.thisTrip': 'This trip',
+ 'dawarich.accept.pickTrip': 'Choose a trip',
+ 'dawarich.accept.day': 'Day',
+ 'dawarich.accept.noDay': 'Not on a day yet',
+ 'dawarich.accept.journal': 'Journal',
+ 'dawarich.accept.pickJournal': 'Choose a journal',
+ 'dawarich.accept.notes': 'Notes',
+ 'dawarich.accept.story': 'Your story',
+ 'dawarich.accept.storyPlaceholder': 'What happened here?',
+ 'dawarich.accept.photosHint': 'Add photos to the entry after it is created.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Added from your Dawarich recordings',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Check your wishlist against Dawarich',
+ 'dawarich.bucket.description':
+ 'Looks through your recordings for the places you wanted to reach. A visit needs both closeness and time spent — driving past does not count.',
+ 'dawarich.bucket.scan': 'Check wishlist',
+ 'dawarich.bucket.scanning': 'Checking…',
+ 'dawarich.bucket.noMatches': 'Nothing on your wishlist turned up in your recordings.',
+ 'dawarich.bucket.alreadyVisited': 'Already ticked off',
+ 'dawarich.bucket.confirm': 'Tick off {count}',
+ 'dawarich.bucket.confirmed': '{count} wishes ticked off',
+ 'dawarich.bucket.skipped': '{count} entries have no coordinates and could not be checked.',
+ 'dawarich.bucket.truncated': 'Only the first entries were checked. Run it again for the rest.',
+ 'dawarich.bucket.visitedFrom': 'Ticked off from your Dawarich recordings',
+ 'dawarich.bucket.clearVisit': 'Undo',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Countries from Dawarich',
+ 'dawarich.atlas.description':
+ 'Countries your recordings say you were in. Confirm the ones you want in your Atlas — nothing is added on its own, and what you marked by hand stays yours.',
+ 'dawarich.atlas.load': 'Look for countries',
+ 'dawarich.atlas.loading': 'Reading your recordings…',
+ 'dawarich.atlas.empty': 'Your recordings show no countries TREK does not already have.',
+ 'dawarich.atlas.cities': '{count} cities',
+ 'dawarich.atlas.citiesOne': '1 city',
+ 'dawarich.atlas.accept': 'Add {count} countries',
+ 'dawarich.atlas.accepted': '{count} countries added',
+ 'dawarich.atlas.unresolved': 'TREK could not match these to a country: {names}.',
+ 'dawarich.atlas.source': 'From Dawarich',
+ 'dawarich.atlas.range': 'Looked at {from} to {to}',
+
+ 'dawarich.atlas.trigger': 'Wishes and countries from your recordings',
+ 'dawarich.atlas.dialogSubtitle': 'What your recordings say about your Atlas',
+ 'dawarich.atlas.tab.wishes': 'Wishlist',
+ 'dawarich.atlas.tab.countries': 'Countries',
+ 'dawarich.atlas.window': 'The last 12 months were looked at.',
+ 'dawarich.selected': '{count} selected',
+ 'dawarich.again': 'Check again',
+ 'dawarich.bucket.metersAway': '{meters} m away',
+ 'dawarich.bucket.kilometersAway': '{km} km away',
+ 'dawarich.bucket.rule': 'A wish counts as reached within {meters} m and after {minutes} minutes on the spot.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/en/index.ts
+++ b/shared/src/i18n/en/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts
index bd2f21608..c4182f486 100644
--- a/shared/src/i18n/en/settings.ts
+++ b/shared/src/i18n/en/settings.ts
@@ -543,6 +543,21 @@ const settings: TranslationStrings = {
'Automatically draw the route for every flight, train and other booking on the map — no need to switch it on per item.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'What this key may read',
+ 'settings.apiScopes.hint':
+ 'Leave everything on for a key that should see all of it. Anything you switch off is refused for this key, not just left out of the answer.',
+ 'settings.apiScopes.all': 'Everything',
+ 'settings.apiScopes.limited': '{count} of {total}',
+ 'settings.apiScopes.trips': 'Trips',
+ 'settings.apiScopes.days': 'Days',
+ 'settings.apiScopes.places': 'Places',
+ 'settings.apiScopes.notes': 'Day notes',
+ 'settings.apiScopes.reservations': 'Bookings',
+ 'settings.apiScopes.accommodations': 'Accommodation',
+ 'settings.apiScopes.travellers': 'Who is coming',
+ 'settings.apiScopes.bucket-list': 'Wishlist',
+ 'settings.apiScopes.stats': 'Totals',
'settings.apiKeys.title': 'API Keys',
'settings.apiKeys.description': 'Keys for the public API, so other software can read your trips. Read-only: a key cannot change or delete anything.',
'settings.apiKeys.create': 'Create key',
diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts
index 66c11e059..5ab67bbcf 100644
--- a/shared/src/i18n/es/admin.ts
+++ b/shared/src/i18n/es/admin.ts
@@ -596,6 +596,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importa lugares desde una lista compartida de Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sincroniza vuelos desde tu instancia de AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Lee estancias y rutas grabadas de una instancia de Dawarich que cada usuario conecta por su cuenta',
'admin.addons.catalog.llm_parsing.name': 'Análisis con IA',
'admin.addons.catalog.llm_parsing.description': 'Lee reservas que el analizador integrado no entiende, con un modelo de IA que tú elijas',
'admin.addons.enabled': 'Activo',
diff --git a/shared/src/i18n/es/dawarich.ts b/shared/src/i18n/es/dawarich.ts
new file mode 100644
index 000000000..8a5aa2874
--- /dev/null
+++ b/shared/src/i18n/es/dawarich.ts
@@ -0,0 +1,180 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Conecta tu propia instancia de Dawarich para ver dónde estuviste realmente. TREK la lee y te sugiere entradas de diario, lugares y países: no se añade nada hasta que lo confirmes y no se escribe nada en Dawarich.',
+ 'dawarich.url': 'Dirección de la instancia',
+ 'dawarich.apiKey': 'Clave de API',
+ 'dawarich.apiKeyPlaceholder': 'Pega tu clave de API de Dawarich',
+ 'dawarich.apiKeyHint':
+ 'La encontrarás en Dawarich en Cuenta → Clave de API. Se guarda cifrada y no se vuelve a mostrar.',
+ 'dawarich.allowInsecureTls': 'Permitir certificado autofirmado',
+ 'dawarich.allowInsecureTlsHint':
+ 'Solo hace falta si tu instancia usa un certificado en el que tu servidor no confía.',
+ 'dawarich.syncEnabled': 'Buscar estancias nuevas automáticamente',
+ 'dawarich.syncEnabledHint': 'Desactivado significa que TREK solo lee Dawarich cuando se lo pides.',
+ 'dawarich.test.button': 'Probar conexión',
+ 'dawarich.test.success': 'Conectado. {count} estancias encontradas en los últimos 30 días.',
+ 'dawarich.test.failed': 'No se pudo contactar con Dawarich.',
+ 'dawarich.syncNow': 'Buscar ahora',
+ 'dawarich.connected': 'Conectado',
+ 'dawarich.notConnected': 'No conectado',
+ 'dawarich.disconnect': 'Desconectar',
+ 'dawarich.lastSync': 'Última comprobación {when}',
+ 'dawarich.neverSynced': 'Aún sin comprobar',
+ 'dawarich.syncPartial': 'algunos viajes no se pudieron leer',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Conexión con Dawarich guardada',
+ 'dawarich.toast.saveError': 'No se pudo guardar la conexión',
+ 'dawarich.toast.disconnected': 'Dawarich desconectado',
+ 'dawarich.toast.synced': '{count} estancias nuevas encontradas',
+ 'dawarich.toast.syncError': 'No se pudo leer Dawarich',
+ 'dawarich.toast.syncRunning': 'Ya hay una comprobación en curso',
+ 'dawarich.toast.acceptError': 'No se pudo añadir esto',
+ 'dawarich.toast.updateError': 'No se pudo actualizar esta sugerencia',
+ 'dawarich.toast.accepted.place': 'Añadido al viaje',
+ 'dawarich.toast.accepted.journal': 'Añadido al diario',
+ 'dawarich.toast.accepted.bucket_list': 'Tachado de tu lista de deseos',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'estancias',
+ 'dawarich.capability.track': 'ruta grabada',
+ 'dawarich.capability.locations': 'coincidencias con la lista de deseos',
+ 'dawarich.capability.visitedCities': 'países y ciudades',
+ 'dawarich.capability.missing': 'Esta versión de Dawarich no ofrece: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK no pudo contactar con esa dirección.',
+ 'dawarich.error.unauthorized': 'Dawarich rechazó la clave de API.',
+ 'dawarich.error.forbidden': 'Esa clave de API no tiene permiso para leer esto.',
+ 'dawarich.error.not_found': 'Esta versión de Dawarich no tiene ese endpoint.',
+ 'dawarich.error.rate_limited': 'Dawarich pidió a TREK que fuera más despacio. Inténtalo de nuevo en un momento.',
+ 'dawarich.error.server_error': 'Dawarich respondió con un error.',
+ 'dawarich.error.invalid_response': 'Esa dirección respondió con algo que no es Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich envió más datos de los que TREK lee de una vez.',
+ 'dawarich.error.not_connected': 'Todavía no hay ninguna instancia de Dawarich conectada.',
+ 'dawarich.error.addon_disabled': 'El complemento de Dawarich está desactivado en esta instancia.',
+ 'dawarich.error.offline': 'Esto necesita conexión: TREK está sin conexión ahora mismo.',
+ 'dawarich.error.invalid_url': 'TREK no puede usar esa dirección.',
+ 'dawarich.warning.private_ip': 'Esa dirección apunta a una IP privada ({ip}). Comprueba que es lo que querías: el servidor puede necesitar ALLOW_INTERNAL_NETWORK=true para alcanzarla.',
+ 'dawarich.error.unknown': 'Algo salió mal al hablar con Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Mostrar ruta grabada',
+ 'dawarich.trail.hide': 'Ocultar ruta grabada',
+ 'dawarich.trail.loading': 'Cargando la ruta grabada…',
+ 'dawarich.trail.empty': 'No se grabó nada en estas fechas',
+ 'dawarich.trail.offline': 'La ruta grabada necesita conexión',
+ 'dawarich.trail.unavailable': 'No se pudo cargar la ruta grabada',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'comprobado {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Incierto',
+ 'dawarich.badge.sourceChanged': 'Cambiado en Dawarich',
+ 'dawarich.badge.sourceMissing': 'Ya no está en Dawarich',
+
+ 'dawarich.suggestions.title': 'Desde Dawarich',
+ 'dawarich.suggestions.pending': '{count} esperándote',
+ 'dawarich.suggestions.loading': 'Leyendo Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Conecta Dawarich en Ajustes para ver aquí tus estancias.',
+ 'dawarich.suggestions.unavailable': 'No se pudo leer Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Ya has resuelto todo lo grabado aquí.',
+ 'dawarich.suggestions.asJournal': 'Escribir una entrada de diario',
+ 'dawarich.suggestions.asPlace': 'Añadir como lugar',
+ 'dawarich.suggestions.dismiss': 'No es un lugar que visitara',
+ 'dawarich.suggestions.dismissed': 'Descartado',
+ 'dawarich.suggestions.restore': 'Devolver',
+ 'dawarich.suggestions.showHandled': 'Mostrar {count} ya resueltas',
+ 'dawarich.suggestions.hideHandled': 'Ocultar las ya resueltas',
+ 'dawarich.suggestions.matchesWish': 'En tu lista de deseos: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Añadido como lugar',
+ 'dawarich.suggestions.acceptedAs.journal': 'En el diario',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Deseo tachado',
+ 'dawarich.suggestions.sourceChanged':
+ 'Esta estancia ha cambiado en Dawarich desde que la usaste. Lo que escribiste en TREK sigue intacto.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Esta estancia ya no existe en Dawarich. Lo que escribiste en TREK sigue intacto.',
+ 'dawarich.sourceStatus.suggested': 'Detectada, sin confirmar',
+ 'dawarich.confidence.high': 'Detección segura',
+ 'dawarich.confidence.medium': 'Detección bastante segura',
+ 'dawarich.confidence.low': 'Detección dudosa',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Añadir esta estancia como lugar',
+ 'dawarich.accept.title.journal': 'Escribir una entrada de diario',
+ 'dawarich.accept.title.bucket_list': 'Tachar un deseo',
+ 'dawarich.accept.confirm.place': 'Añadir lugar',
+ 'dawarich.accept.confirm.journal': 'Añadir entrada',
+ 'dawarich.accept.confirm.bucket_list': 'Tacharlo',
+ 'dawarich.accept.recorded': 'Grabado de {from} a {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nombre',
+ 'dawarich.accept.date': 'Fecha',
+ 'dawarich.accept.from': 'Llegada',
+ 'dawarich.accept.to': 'Salida',
+ 'dawarich.accept.trip': 'Viaje',
+ 'dawarich.accept.thisTrip': 'Este viaje',
+ 'dawarich.accept.pickTrip': 'Elige un viaje',
+ 'dawarich.accept.day': 'Día',
+ 'dawarich.accept.noDay': 'Aún sin día asignado',
+ 'dawarich.accept.journal': 'Diario',
+ 'dawarich.accept.pickJournal': 'Elige un diario',
+ 'dawarich.accept.notes': 'Notas',
+ 'dawarich.accept.story': 'Tu historia',
+ 'dawarich.accept.storyPlaceholder': '¿Qué pasó aquí?',
+ 'dawarich.accept.photosHint': 'Añade fotos a la entrada una vez creada.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Añadido desde tus registros de Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Contrasta tu lista de deseos con Dawarich',
+ 'dawarich.bucket.description':
+ 'Busca en tus grabaciones los lugares a los que querías llegar. Una visita necesita cercanía y tiempo: pasar de largo no cuenta.',
+ 'dawarich.bucket.scan': 'Comprobar la lista de deseos',
+ 'dawarich.bucket.scanning': 'Comprobando…',
+ 'dawarich.bucket.noMatches': 'No apareció nada de tu lista de deseos en tus grabaciones.',
+ 'dawarich.bucket.alreadyVisited': 'Ya tachado',
+ 'dawarich.bucket.confirm': 'Tachar {count}',
+ 'dawarich.bucket.confirmed': '{count} deseos tachados',
+ 'dawarich.bucket.skipped': '{count} entradas no tienen coordenadas y no se pudieron comprobar.',
+ 'dawarich.bucket.truncated': 'Solo se comprobaron las primeras entradas. Vuelve a ejecutarlo para el resto.',
+ 'dawarich.bucket.visitedFrom': 'Tachado a partir de tus grabaciones de Dawarich',
+ 'dawarich.bucket.clearVisit': 'Deshacer',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Países desde Dawarich',
+ 'dawarich.atlas.description':
+ 'Países en los que, según tus grabaciones, estuviste. Confirma los que quieras en tu Atlas: no se añade nada por su cuenta y lo que marcaste a mano sigue siendo tuyo.',
+ 'dawarich.atlas.load': 'Buscar países',
+ 'dawarich.atlas.loading': 'Leyendo tus grabaciones…',
+ 'dawarich.atlas.empty': 'Tus grabaciones no muestran ningún país que TREK no tenga ya.',
+ 'dawarich.atlas.cities': '{count} ciudades',
+ 'dawarich.atlas.citiesOne': '1 ciudad',
+ 'dawarich.atlas.accept': 'Añadir {count} países',
+ 'dawarich.atlas.accepted': '{count} países añadidos',
+ 'dawarich.atlas.unresolved': 'TREK no pudo asociar estos a ningún país: {names}.',
+ 'dawarich.atlas.source': 'Desde Dawarich',
+ 'dawarich.atlas.range': 'Revisado del {from} al {to}',
+
+ 'dawarich.atlas.trigger': 'Deseos y países de tus registros',
+ 'dawarich.atlas.dialogSubtitle': 'Lo que tus registros dicen de tu Atlas',
+ 'dawarich.atlas.tab.wishes': 'Lista de deseos',
+ 'dawarich.atlas.tab.countries': 'Países',
+ 'dawarich.atlas.window': 'Se han revisado los últimos 12 meses.',
+ 'dawarich.selected': '{count} seleccionados',
+ 'dawarich.again': 'Volver a comprobar',
+ 'dawarich.bucket.metersAway': 'a {meters} m',
+ 'dawarich.bucket.kilometersAway': 'a {km} km',
+ 'dawarich.bucket.rule': 'Un deseo cuenta como cumplido a menos de {meters} m y tras {minutes} minutos en el lugar.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts
index db8e8d078..f6c96ab9b 100644
--- a/shared/src/i18n/es/index.ts
+++ b/shared/src/i18n/es/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts
index d8ab3e55c..ba5d36976 100644
--- a/shared/src/i18n/es/settings.ts
+++ b/shared/src/i18n/es/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Muestra automáticamente en el mapa la ruta de cada vuelo, tren y otra reserva, sin necesidad de activarla una por una.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Qué puede leer esta clave',
+ 'settings.apiScopes.hint':
+ 'Déjalo todo activado para una clave que deba verlo todo. Lo que desactives se le deniega a esta clave, no solo se omite de la respuesta.',
+ 'settings.apiScopes.all': 'Todo',
+ 'settings.apiScopes.limited': '{count} de {total}',
+ 'settings.apiScopes.trips': 'Viajes',
+ 'settings.apiScopes.days': 'Días',
+ 'settings.apiScopes.places': 'Lugares',
+ 'settings.apiScopes.notes': 'Notas del día',
+ 'settings.apiScopes.reservations': 'Reservas',
+ 'settings.apiScopes.accommodations': 'Alojamiento',
+ 'settings.apiScopes.travellers': 'Quién viene',
+ 'settings.apiScopes.bucket-list': 'Lista de deseos',
+ 'settings.apiScopes.stats': 'Totales',
'settings.apiKeys.title': 'Claves API',
'settings.apiKeys.description': 'Claves para la API pública, para que otro software pueda leer tus viajes. Solo lectura: una clave no puede cambiar ni borrar nada.',
'settings.apiKeys.create': 'Crear clave',
diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts
index 3a5086564..c85fa6ad2 100644
--- a/shared/src/i18n/fr/admin.ts
+++ b/shared/src/i18n/fr/admin.ts
@@ -617,6 +617,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importez des lieux depuis une liste Naver Maps partagée',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Synchronisez les vols depuis votre instance AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Lisez les séjours et les trajets enregistrés depuis une instance Dawarich que chaque utilisateur connecte lui-même',
'admin.addons.catalog.llm_parsing.name': 'Analyse par IA',
'admin.addons.catalog.llm_parsing.description': 'Lit les réservations que l’analyseur intégré ne comprend pas, via un modèle d’IA de votre choix',
'admin.addons.enabled': 'Activé',
diff --git a/shared/src/i18n/fr/dawarich.ts b/shared/src/i18n/fr/dawarich.ts
new file mode 100644
index 000000000..0c4612a55
--- /dev/null
+++ b/shared/src/i18n/fr/dawarich.ts
@@ -0,0 +1,182 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Connectez votre propre instance Dawarich pour voir où vous êtes réellement allé. TREK la consulte et vous propose des entrées de journal, des lieux et des pays — rien n’est ajouté sans votre confirmation, et rien n’est réécrit dans Dawarich.',
+ 'dawarich.url': 'Adresse de l’instance',
+ 'dawarich.apiKey': 'Clé API',
+ 'dawarich.apiKeyPlaceholder': 'Collez votre clé API Dawarich',
+ 'dawarich.apiKeyHint':
+ 'Disponible dans Dawarich sous Compte → Clé API. Stockée chiffrée et jamais affichée à nouveau.',
+ 'dawarich.allowInsecureTls': 'Autoriser un certificat auto-signé',
+ 'dawarich.allowInsecureTlsHint':
+ 'Nécessaire uniquement si votre instance utilise un certificat auquel votre serveur ne fait pas confiance.',
+ 'dawarich.syncEnabled': 'Rechercher automatiquement les nouveaux séjours',
+ 'dawarich.syncEnabledHint': 'Désactivé, TREK ne consulte Dawarich que lorsque vous le lui demandez.',
+ 'dawarich.test.button': 'Tester la connexion',
+ 'dawarich.test.success': 'Connecté. {count} séjours trouvés sur les 30 derniers jours.',
+ 'dawarich.test.failed': 'Impossible de joindre Dawarich.',
+ 'dawarich.syncNow': 'Vérifier maintenant',
+ 'dawarich.connected': 'Connecté',
+ 'dawarich.notConnected': 'Non connecté',
+ 'dawarich.disconnect': 'Déconnecter',
+ 'dawarich.lastSync': 'Dernière vérification {when}',
+ 'dawarich.neverSynced': 'Jamais vérifié',
+ 'dawarich.syncPartial': 'certains voyages n’ont pas pu être lus',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Connexion Dawarich enregistrée',
+ 'dawarich.toast.saveError': 'Impossible d’enregistrer la connexion',
+ 'dawarich.toast.disconnected': 'Dawarich déconnecté',
+ 'dawarich.toast.synced': '{count} nouveaux séjours trouvés',
+ 'dawarich.toast.syncError': 'Impossible de lire Dawarich',
+ 'dawarich.toast.syncRunning': 'Une vérification est déjà en cours',
+ 'dawarich.toast.acceptError': 'Impossible d’ajouter cet élément',
+ 'dawarich.toast.updateError': 'Impossible de mettre à jour cette suggestion',
+ 'dawarich.toast.accepted.place': 'Ajouté au voyage',
+ 'dawarich.toast.accepted.journal': 'Ajouté au journal',
+ 'dawarich.toast.accepted.bucket_list': 'Coché dans votre liste de souhaits',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'séjours',
+ 'dawarich.capability.track': 'trajet enregistré',
+ 'dawarich.capability.locations': 'correspondance avec la liste de souhaits',
+ 'dawarich.capability.visitedCities': 'pays et villes',
+ 'dawarich.capability.missing': 'Cette version de Dawarich ne propose pas : {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK n’a pas pu joindre cette adresse.',
+ 'dawarich.error.unauthorized': 'Dawarich a refusé la clé API.',
+ 'dawarich.error.forbidden': 'Cette clé API n’a pas le droit de lire ces données.',
+ 'dawarich.error.not_found': 'Cette version de Dawarich ne dispose pas de ce point d’accès.',
+ 'dawarich.error.rate_limited': 'Dawarich a demandé à TREK de ralentir. Réessayez dans un instant.',
+ 'dawarich.error.server_error': 'Dawarich a répondu par une erreur.',
+ 'dawarich.error.invalid_response': 'Cette adresse a répondu quelque chose qui n’est pas Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich a envoyé plus de données que TREK n’en lit en une fois.',
+ 'dawarich.error.not_connected': 'Aucune instance Dawarich n’est encore connectée.',
+ 'dawarich.error.addon_disabled': 'Le module Dawarich est désactivé sur cette instance.',
+ 'dawarich.error.offline': 'Cela demande une connexion — TREK est hors ligne pour le moment.',
+ 'dawarich.error.invalid_url': 'TREK ne peut pas utiliser cette adresse.',
+ 'dawarich.warning.private_ip': 'Cette adresse pointe vers une IP privée ({ip}). Vérifiez que c’est voulu — le serveur peut avoir besoin de ALLOW_INTERNAL_NETWORK=true pour l’atteindre.',
+ 'dawarich.error.unknown': 'Un problème est survenu lors de l’échange avec Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Afficher le trajet enregistré',
+ 'dawarich.trail.hide': 'Masquer le trajet enregistré',
+ 'dawarich.trail.loading': 'Chargement du trajet enregistré…',
+ 'dawarich.trail.empty': 'Rien n’a été enregistré à ces dates',
+ 'dawarich.trail.offline': 'Le trajet enregistré nécessite une connexion',
+ 'dawarich.trail.unavailable': 'Le trajet enregistré n’a pas pu être chargé',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'vérifié {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Incertain',
+ 'dawarich.badge.sourceChanged': 'Modifié dans Dawarich',
+ 'dawarich.badge.sourceMissing': 'Disparu de Dawarich',
+
+ 'dawarich.suggestions.title': 'Depuis Dawarich',
+ 'dawarich.suggestions.pending': '{count} en attente',
+ 'dawarich.suggestions.loading': 'Lecture de Dawarich…',
+ 'dawarich.suggestions.notConnected':
+ 'Connectez Dawarich dans les Paramètres pour voir vos séjours ici.',
+ 'dawarich.suggestions.unavailable': 'Dawarich n’a pas pu être lu.',
+ 'dawarich.suggestions.allHandled': 'Tout ce qui a été enregistré ici a été traité.',
+ 'dawarich.suggestions.asJournal': 'Écrire une entrée de journal',
+ 'dawarich.suggestions.asPlace': 'Ajouter comme lieu',
+ 'dawarich.suggestions.dismiss': 'Je n’ai pas visité ce lieu',
+ 'dawarich.suggestions.dismissed': 'Ignoré',
+ 'dawarich.suggestions.restore': 'Rétablir',
+ 'dawarich.suggestions.showHandled': 'Afficher les {count} déjà traités',
+ 'dawarich.suggestions.hideHandled': 'Masquer ceux déjà traités',
+ 'dawarich.suggestions.matchesWish': 'Dans votre liste de souhaits : {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Ajouté comme lieu',
+ 'dawarich.suggestions.acceptedAs.journal': 'Dans le journal',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Souhait coché',
+ 'dawarich.suggestions.sourceChanged':
+ 'Ce séjour a changé dans Dawarich depuis que vous l’avez utilisé. Ce que vous avez écrit dans TREK reste intact.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Ce séjour n’existe plus dans Dawarich. Ce que vous avez écrit dans TREK reste intact.',
+ 'dawarich.sourceStatus.suggested': 'Détecté, non confirmé',
+ 'dawarich.confidence.high': 'Détection sûre',
+ 'dawarich.confidence.medium': 'Détection plutôt sûre',
+ 'dawarich.confidence.low': 'Détection incertaine',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Ajouter ce séjour comme lieu',
+ 'dawarich.accept.title.journal': 'Écrire une entrée de journal',
+ 'dawarich.accept.title.bucket_list': 'Cocher un souhait',
+ 'dawarich.accept.confirm.place': 'Ajouter le lieu',
+ 'dawarich.accept.confirm.journal': 'Ajouter l’entrée',
+ 'dawarich.accept.confirm.bucket_list': 'Le cocher',
+ 'dawarich.accept.recorded': 'Enregistré du {from} au {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nom',
+ 'dawarich.accept.date': 'Date',
+ 'dawarich.accept.from': 'Arrivée',
+ 'dawarich.accept.to': 'Départ',
+ 'dawarich.accept.trip': 'Voyage',
+ 'dawarich.accept.thisTrip': 'Ce voyage',
+ 'dawarich.accept.pickTrip': 'Choisir un voyage',
+ 'dawarich.accept.day': 'Jour',
+ 'dawarich.accept.noDay': 'Pas encore rattaché à un jour',
+ 'dawarich.accept.journal': 'Journal',
+ 'dawarich.accept.pickJournal': 'Choisir un journal',
+ 'dawarich.accept.notes': 'Notes',
+ 'dawarich.accept.story': 'Votre histoire',
+ 'dawarich.accept.storyPlaceholder': 'Que s’est-il passé ici ?',
+ 'dawarich.accept.photosHint': 'Ajoutez des photos à l’entrée une fois qu’elle est créée.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Ajouté depuis vos enregistrements Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Confronter votre liste de souhaits à Dawarich',
+ 'dawarich.bucket.description':
+ 'Parcourt vos enregistrements à la recherche des lieux que vous vouliez atteindre. Une visite exige à la fois la proximité et du temps passé sur place — passer devant en voiture ne compte pas.',
+ 'dawarich.bucket.scan': 'Vérifier la liste de souhaits',
+ 'dawarich.bucket.scanning': 'Vérification…',
+ 'dawarich.bucket.noMatches': 'Rien de votre liste de souhaits n’apparaît dans vos enregistrements.',
+ 'dawarich.bucket.alreadyVisited': 'Déjà coché',
+ 'dawarich.bucket.confirm': 'Cocher {count}',
+ 'dawarich.bucket.confirmed': '{count} souhaits cochés',
+ 'dawarich.bucket.skipped': '{count} entrées n’ont pas de coordonnées et n’ont pas pu être vérifiées.',
+ 'dawarich.bucket.truncated':
+ 'Seules les premières entrées ont été vérifiées. Relancez pour traiter le reste.',
+ 'dawarich.bucket.visitedFrom': 'Coché à partir de vos enregistrements Dawarich',
+ 'dawarich.bucket.clearVisit': 'Annuler',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Pays issus de Dawarich',
+ 'dawarich.atlas.description':
+ 'Les pays où vos enregistrements indiquent que vous êtes allé. Confirmez ceux que vous voulez dans votre Atlas — rien n’est ajouté tout seul, et ce que vous avez marqué à la main vous appartient.',
+ 'dawarich.atlas.load': 'Rechercher des pays',
+ 'dawarich.atlas.loading': 'Lecture de vos enregistrements…',
+ 'dawarich.atlas.empty': 'Vos enregistrements ne révèlent aucun pays que TREK ne connaisse déjà.',
+ 'dawarich.atlas.cities': '{count} villes',
+ 'dawarich.atlas.citiesOne': '1 ville',
+ 'dawarich.atlas.accept': 'Ajouter {count} pays',
+ 'dawarich.atlas.accepted': '{count} pays ajoutés',
+ 'dawarich.atlas.unresolved': 'TREK n’a pas pu rattacher ceci à un pays : {names}.',
+ 'dawarich.atlas.source': 'Depuis Dawarich',
+ 'dawarich.atlas.range': 'Période examinée : du {from} au {to}',
+
+ 'dawarich.atlas.trigger': 'Envies et pays issus de vos enregistrements',
+ 'dawarich.atlas.dialogSubtitle': 'Ce que vos enregistrements disent de votre Atlas',
+ 'dawarich.atlas.tab.wishes': 'Liste d’envies',
+ 'dawarich.atlas.tab.countries': 'Pays',
+ 'dawarich.atlas.window': 'Les 12 derniers mois ont été examinés.',
+ 'dawarich.selected': '{count} sélectionnés',
+ 'dawarich.again': 'Vérifier à nouveau',
+ 'dawarich.bucket.metersAway': 'à {meters} m',
+ 'dawarich.bucket.kilometersAway': 'à {km} km',
+ 'dawarich.bucket.rule': 'Une envie est atteinte à moins de {meters} m et après {minutes} minutes sur place.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/fr/index.ts
+++ b/shared/src/i18n/fr/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts
index a36a95eeb..edb9d9b1d 100644
--- a/shared/src/i18n/fr/settings.ts
+++ b/shared/src/i18n/fr/settings.ts
@@ -561,6 +561,21 @@ const settings: TranslationStrings = {
"Affiche automatiquement sur la carte l'itinéraire de chaque vol, trajet en train et autre réservation, sans avoir à l'activer un par un.",
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Ce que cette clé peut lire',
+ 'settings.apiScopes.hint':
+ 'Laissez tout activé pour une clé qui doit tout voir. Ce que vous désactivez est refusé à cette clé, et pas seulement omis de la réponse.',
+ 'settings.apiScopes.all': 'Tout',
+ 'settings.apiScopes.limited': '{count} sur {total}',
+ 'settings.apiScopes.trips': 'Voyages',
+ 'settings.apiScopes.days': 'Jours',
+ 'settings.apiScopes.places': 'Lieux',
+ 'settings.apiScopes.notes': 'Notes du jour',
+ 'settings.apiScopes.reservations': 'Réservations',
+ 'settings.apiScopes.accommodations': 'Hébergement',
+ 'settings.apiScopes.travellers': 'Qui participe',
+ 'settings.apiScopes.bucket-list': 'Liste de souhaits',
+ 'settings.apiScopes.stats': 'Totaux',
'settings.apiKeys.title': 'Clés API',
'settings.apiKeys.description': 'Clés pour l\'API publique, afin que d\'autres logiciels puissent lire vos voyages. Lecture seule : une clé ne peut rien modifier ni supprimer.',
'settings.apiKeys.create': 'Créer une clé',
diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts
index 2dd5d5b1c..382e078a4 100644
--- a/shared/src/i18n/gr/admin.ts
+++ b/shared/src/i18n/gr/admin.ts
@@ -668,6 +668,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Εισαγωγή τοποθεσιών από κοινόχρηστη λίστα Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Συγχρονισμός πτήσεων από τη δική σας εγκατάσταση AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Ανάγνωση επισκέψεων και καταγεγραμμένων διαδρομών από μια εγκατάσταση Dawarich που συνδέει ο κάθε χρήστης μόνος του',
'admin.addons.catalog.llm_parsing.name': 'Ανάλυση με τεχνητή νοημοσύνη',
'admin.addons.catalog.llm_parsing.description': 'Διαβάζει κρατήσεις που δεν καταλαβαίνει ο ενσωματωμένος αναλυτής, με μοντέλο ΤΝ της επιλογής σας',
'admin.addons.enabled': 'Ενεργοποιημένο',
diff --git a/shared/src/i18n/gr/dawarich.ts b/shared/src/i18n/gr/dawarich.ts
new file mode 100644
index 000000000..a0f9d0767
--- /dev/null
+++ b/shared/src/i18n/gr/dawarich.ts
@@ -0,0 +1,183 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Συνδέστε τη δική σας εγκατάσταση Dawarich για να δείτε πού βρεθήκατε πραγματικά. Το TREK τη διαβάζει και προτείνει καταχωρήσεις ημερολογίου, τοποθεσίες και χώρες — τίποτα δεν προστίθεται πριν το επιβεβαιώσετε και τίποτα δεν γράφεται πίσω στο Dawarich.',
+ 'dawarich.url': 'Διεύθυνση εγκατάστασης',
+ 'dawarich.apiKey': 'Κλειδί API',
+ 'dawarich.apiKeyPlaceholder': 'Επικολλήστε το κλειδί API του Dawarich',
+ 'dawarich.apiKeyHint':
+ 'Βρίσκεται στο Dawarich στο Λογαριασμός → Κλειδί API. Αποθηκεύεται κρυπτογραφημένο και δεν εμφανίζεται ξανά.',
+ 'dawarich.allowInsecureTls': 'Να επιτρέπεται αυτο-υπογεγραμμένο πιστοποιητικό',
+ 'dawarich.allowInsecureTlsHint':
+ 'Χρειάζεται μόνο αν η εγκατάστασή σας χρησιμοποιεί πιστοποιητικό που δεν εμπιστεύεται ο διακομιστής σας.',
+ 'dawarich.syncEnabled': 'Αυτόματος έλεγχος για νέες στάσεις',
+ 'dawarich.syncEnabledHint':
+ 'Απενεργοποιημένο σημαίνει ότι το TREK διαβάζει το Dawarich μόνο όταν του το ζητήσετε.',
+ 'dawarich.test.button': 'Δοκιμή σύνδεσης',
+ 'dawarich.test.success': 'Συνδέθηκε. Βρέθηκαν {count} στάσεις τις τελευταίες 30 ημέρες.',
+ 'dawarich.test.failed': 'Δεν ήταν δυνατή η επικοινωνία με το Dawarich.',
+ 'dawarich.syncNow': 'Έλεγχος τώρα',
+ 'dawarich.connected': 'Συνδεδεμένο',
+ 'dawarich.notConnected': 'Μη συνδεδεμένο',
+ 'dawarich.disconnect': 'Αποσύνδεση',
+ 'dawarich.lastSync': 'Τελευταίος έλεγχος {when}',
+ 'dawarich.neverSynced': 'Δεν έχει ελεγχθεί ακόμα',
+ 'dawarich.syncPartial': 'ορισμένα ταξίδια δεν ήταν δυνατό να διαβαστούν',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Η σύνδεση με το Dawarich αποθηκεύτηκε',
+ 'dawarich.toast.saveError': 'Δεν ήταν δυνατή η αποθήκευση της σύνδεσης',
+ 'dawarich.toast.disconnected': 'Το Dawarich αποσυνδέθηκε',
+ 'dawarich.toast.synced': 'Βρέθηκαν {count} νέες στάσεις',
+ 'dawarich.toast.syncError': 'Δεν ήταν δυνατή η ανάγνωση του Dawarich',
+ 'dawarich.toast.syncRunning': 'Εκτελείται ήδη έλεγχος',
+ 'dawarich.toast.acceptError': 'Δεν ήταν δυνατή η προσθήκη',
+ 'dawarich.toast.updateError': 'Δεν ήταν δυνατή η ενημέρωση αυτής της πρότασης',
+ 'dawarich.toast.accepted.place': 'Προστέθηκε στο ταξίδι',
+ 'dawarich.toast.accepted.journal': 'Προστέθηκε στο ημερολόγιο',
+ 'dawarich.toast.accepted.bucket_list': 'Σημειώθηκε ως εκπληρωμένη επιθυμία',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'στάσεις',
+ 'dawarich.capability.track': 'καταγεγραμμένη διαδρομή',
+ 'dawarich.capability.locations': 'αντιστοίχιση λίστας επιθυμιών',
+ 'dawarich.capability.visitedCities': 'χώρες και πόλεις',
+ 'dawarich.capability.missing': 'Αυτή η έκδοση του Dawarich δεν προσφέρει: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'Το TREK δεν μπόρεσε να επικοινωνήσει με αυτή τη διεύθυνση.',
+ 'dawarich.error.unauthorized': 'Το Dawarich απέρριψε το κλειδί API.',
+ 'dawarich.error.forbidden': 'Αυτό το κλειδί API δεν επιτρέπεται να διαβάσει κάτι τέτοιο.',
+ 'dawarich.error.not_found': 'Αυτή η έκδοση του Dawarich δεν διαθέτει αυτό το σημείο πρόσβασης.',
+ 'dawarich.error.rate_limited': 'Το Dawarich ζήτησε από το TREK να επιβραδύνει. Δοκιμάστε ξανά σε λίγο.',
+ 'dawarich.error.server_error': 'Το Dawarich απάντησε με σφάλμα.',
+ 'dawarich.error.invalid_response': 'Αυτή η διεύθυνση απάντησε με κάτι που δεν είναι Dawarich.',
+ 'dawarich.error.too_large': 'Το Dawarich έστειλε περισσότερα δεδομένα από όσα διαβάζει το TREK με τη μία.',
+ 'dawarich.error.not_connected': 'Δεν έχει συνδεθεί ακόμα καμία εγκατάσταση Dawarich.',
+ 'dawarich.error.addon_disabled': 'Το πρόσθετο Dawarich είναι απενεργοποιημένο σε αυτή την εγκατάσταση.',
+ 'dawarich.error.offline': 'Αυτό χρειάζεται σύνδεση — το TREK είναι εκτός σύνδεσης αυτή τη στιγμή.',
+ 'dawarich.error.invalid_url': 'Το TREK δεν μπορεί να χρησιμοποιήσει αυτή τη διεύθυνση.',
+ 'dawarich.warning.private_ip': 'Αυτή η διεύθυνση οδηγεί σε ιδιωτική IP ({ip}). Βεβαιωθείτε ότι αυτό εννοούσατε — ο διακομιστής ίσως χρειάζεται ALLOW_INTERNAL_NETWORK=true.',
+ 'dawarich.error.unknown': 'Κάτι πήγε στραβά στην επικοινωνία με το Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Εμφάνιση καταγεγραμμένης διαδρομής',
+ 'dawarich.trail.hide': 'Απόκρυψη καταγεγραμμένης διαδρομής',
+ 'dawarich.trail.loading': 'Φόρτωση της καταγεγραμμένης διαδρομής…',
+ 'dawarich.trail.empty': 'Δεν καταγράφηκε τίποτα αυτές τις ημερομηνίες',
+ 'dawarich.trail.offline': 'Η καταγεγραμμένη διαδρομή χρειάζεται σύνδεση',
+ 'dawarich.trail.unavailable': 'Δεν ήταν δυνατή η φόρτωση της καταγεγραμμένης διαδρομής',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} λ',
+ 'dawarich.duration.hours': '{hours} ώ',
+ 'dawarich.duration.hoursMinutes': '{hours} ώ {minutes} λ',
+ 'dawarich.checkedAgo': 'ελέγχθηκε {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Αβέβαιο',
+ 'dawarich.badge.sourceChanged': 'Άλλαξε στο Dawarich',
+ 'dawarich.badge.sourceMissing': 'Χάθηκε από το Dawarich',
+
+ 'dawarich.suggestions.title': 'Από το Dawarich',
+ 'dawarich.suggestions.pending': '{count} περιμένουν από εσάς',
+ 'dawarich.suggestions.loading': 'Ανάγνωση του Dawarich…',
+ 'dawarich.suggestions.notConnected':
+ 'Συνδέστε το Dawarich στις Ρυθμίσεις για να βλέπετε εδώ τις στάσεις σας.',
+ 'dawarich.suggestions.unavailable': 'Δεν ήταν δυνατή η ανάγνωση του Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Όλα όσα καταγράφηκαν εδώ έχουν διεκπεραιωθεί.',
+ 'dawarich.suggestions.asJournal': 'Γράψτε καταχώρηση ημερολογίου',
+ 'dawarich.suggestions.asPlace': 'Προσθήκη ως τοποθεσία',
+ 'dawarich.suggestions.dismiss': 'Δεν είναι μέρος που επισκέφθηκα',
+ 'dawarich.suggestions.dismissed': 'Απορρίφθηκε',
+ 'dawarich.suggestions.restore': 'Επαναφορά',
+ 'dawarich.suggestions.showHandled': 'Εμφάνιση {count} που έχουν ήδη διεκπεραιωθεί',
+ 'dawarich.suggestions.hideHandled': 'Απόκρυψη όσων έχουν ήδη διεκπεραιωθεί',
+ 'dawarich.suggestions.matchesWish': 'Στη λίστα επιθυμιών σας: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Προστέθηκε ως τοποθεσία',
+ 'dawarich.suggestions.acceptedAs.journal': 'Στο ημερολόγιο',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Η επιθυμία σημειώθηκε ως εκπληρωμένη',
+ 'dawarich.suggestions.sourceChanged':
+ 'Αυτή η στάση έχει αλλάξει στο Dawarich από τότε που τη χρησιμοποιήσατε. Ό,τι γράψατε στο TREK παραμένει ανέπαφο.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Αυτή η στάση δεν υπάρχει πλέον στο Dawarich. Ό,τι γράψατε στο TREK παραμένει ανέπαφο.',
+ 'dawarich.sourceStatus.suggested': 'Εντοπίστηκε, χωρίς επιβεβαίωση',
+ 'dawarich.confidence.high': 'Εντοπισμός με βεβαιότητα',
+ 'dawarich.confidence.medium': 'Εντοπισμός με σχετική βεβαιότητα',
+ 'dawarich.confidence.low': 'Αβέβαιος εντοπισμός',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Προσθήκη αυτής της στάσης ως τοποθεσία',
+ 'dawarich.accept.title.journal': 'Γράψτε καταχώρηση ημερολογίου',
+ 'dawarich.accept.title.bucket_list': 'Σημειώστε μια επιθυμία ως εκπληρωμένη',
+ 'dawarich.accept.confirm.place': 'Προσθήκη τοποθεσίας',
+ 'dawarich.accept.confirm.journal': 'Προσθήκη καταχώρησης',
+ 'dawarich.accept.confirm.bucket_list': 'Σημείωση ως εκπληρωμένης',
+ 'dawarich.accept.recorded': 'Καταγράφηκε από {from} έως {to}',
+ 'dawarich.accept.duration': '{minutes} λεπτά',
+ 'dawarich.accept.name': 'Όνομα',
+ 'dawarich.accept.date': 'Ημερομηνία',
+ 'dawarich.accept.from': 'Άφιξη',
+ 'dawarich.accept.to': 'Αναχώρηση',
+ 'dawarich.accept.trip': 'Ταξίδι',
+ 'dawarich.accept.thisTrip': 'Αυτό το ταξίδι',
+ 'dawarich.accept.pickTrip': 'Επιλέξτε ταξίδι',
+ 'dawarich.accept.day': 'Ημέρα',
+ 'dawarich.accept.noDay': 'Χωρίς ημέρα ακόμα',
+ 'dawarich.accept.journal': 'Ημερολόγιο',
+ 'dawarich.accept.pickJournal': 'Επιλέξτε ημερολόγιο',
+ 'dawarich.accept.notes': 'Σημειώσεις',
+ 'dawarich.accept.story': 'Η ιστορία σας',
+ 'dawarich.accept.storyPlaceholder': 'Τι συνέβη εδώ;',
+ 'dawarich.accept.photosHint': 'Προσθέστε φωτογραφίες στην καταχώρηση αφού δημιουργηθεί.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Προστέθηκε από τις καταγραφές σας στο Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Ελέγξτε τη λίστα επιθυμιών σας με το Dawarich',
+ 'dawarich.bucket.description':
+ 'Ψάχνει στις καταγραφές σας για τα μέρη που θέλατε να φτάσετε. Μια επίσκεψη χρειάζεται και εγγύτητα και χρόνο παραμονής — το να περάσετε απλώς από μπροστά δεν μετράει.',
+ 'dawarich.bucket.scan': 'Έλεγχος λίστας επιθυμιών',
+ 'dawarich.bucket.scanning': 'Γίνεται έλεγχος…',
+ 'dawarich.bucket.noMatches': 'Τίποτα από τη λίστα επιθυμιών σας δεν εντοπίστηκε στις καταγραφές σας.',
+ 'dawarich.bucket.alreadyVisited': 'Ήδη σημειωμένο ως εκπληρωμένο',
+ 'dawarich.bucket.confirm': 'Σημείωση {count} ως εκπληρωμένων',
+ 'dawarich.bucket.confirmed': '{count} επιθυμίες σημειώθηκαν ως εκπληρωμένες',
+ 'dawarich.bucket.skipped': '{count} καταχωρήσεις δεν έχουν συντεταγμένες και δεν ήταν δυνατό να ελεγχθούν.',
+ 'dawarich.bucket.truncated':
+ 'Ελέγχθηκαν μόνο οι πρώτες καταχωρήσεις. Εκτελέστε το ξανά για τις υπόλοιπες.',
+ 'dawarich.bucket.visitedFrom': 'Σημειώθηκε ως εκπληρωμένη από τις καταγραφές σας στο Dawarich',
+ 'dawarich.bucket.clearVisit': 'Αναίρεση',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Χώρες από το Dawarich',
+ 'dawarich.atlas.description':
+ 'Χώρες στις οποίες βρεθήκατε σύμφωνα με τις καταγραφές σας. Επιβεβαιώστε όσες θέλετε στο Atlas σας — τίποτα δεν προστίθεται από μόνο του και όσα σημειώσατε χειροκίνητα παραμένουν δικά σας.',
+ 'dawarich.atlas.load': 'Αναζήτηση χωρών',
+ 'dawarich.atlas.loading': 'Ανάγνωση των καταγραφών σας…',
+ 'dawarich.atlas.empty': 'Οι καταγραφές σας δεν δείχνουν χώρες που δεν έχει ήδη το TREK.',
+ 'dawarich.atlas.cities': '{count} πόλεις',
+ 'dawarich.atlas.citiesOne': '1 πόλη',
+ 'dawarich.atlas.accept': 'Προσθήκη {count} χωρών',
+ 'dawarich.atlas.accepted': '{count} χώρες προστέθηκαν',
+ 'dawarich.atlas.unresolved': 'Το TREK δεν μπόρεσε να τα αντιστοιχίσει σε χώρα: {names}.',
+ 'dawarich.atlas.source': 'Από το Dawarich',
+ 'dawarich.atlas.range': 'Εξετάστηκε το διάστημα {from} έως {to}',
+
+ 'dawarich.atlas.trigger': 'Επιθυμίες και χώρες από τις καταγραφές σας',
+ 'dawarich.atlas.dialogSubtitle': 'Τι λένε οι καταγραφές σας για το Atlas σας',
+ 'dawarich.atlas.tab.wishes': 'Λίστα επιθυμιών',
+ 'dawarich.atlas.tab.countries': 'Χώρες',
+ 'dawarich.atlas.window': 'Ελέγχθηκαν οι τελευταίοι 12 μήνες.',
+ 'dawarich.selected': 'Επιλέχθηκαν {count}',
+ 'dawarich.again': 'Έλεγχος ξανά',
+ 'dawarich.bucket.metersAway': '{meters} μ. μακριά',
+ 'dawarich.bucket.kilometersAway': '{km} χλμ. μακριά',
+ 'dawarich.bucket.rule': 'Μια επιθυμία μετράει ως εκπληρωμένη σε απόσταση έως {meters} μ. και μετά από {minutes} λεπτά στο σημείο.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/gr/index.ts
+++ b/shared/src/i18n/gr/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts
index 61a125201..ef4a4dae9 100644
--- a/shared/src/i18n/gr/settings.ts
+++ b/shared/src/i18n/gr/settings.ts
@@ -564,6 +564,21 @@ const settings: TranslationStrings = {
'Εμφανίζει αυτόματα στον χάρτη τη διαδρομή κάθε πτήσης, τρένου και άλλης κράτησης, χωρίς να χρειάζεται να την ενεργοποιείτε μία προς μία.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Τι μπορεί να διαβάσει αυτό το κλειδί',
+ 'settings.apiScopes.hint':
+ 'Αφήστε τα όλα ενεργά για ένα κλειδί που πρέπει να τα βλέπει όλα. Ό,τι απενεργοποιήσετε απορρίπτεται για αυτό το κλειδί, δεν παραλείπεται απλώς από την απάντηση.',
+ 'settings.apiScopes.all': 'Τα πάντα',
+ 'settings.apiScopes.limited': '{count} από {total}',
+ 'settings.apiScopes.trips': 'Ταξίδια',
+ 'settings.apiScopes.days': 'Ημέρες',
+ 'settings.apiScopes.places': 'Τοποθεσίες',
+ 'settings.apiScopes.notes': 'Σημειώσεις ημέρας',
+ 'settings.apiScopes.reservations': 'Κρατήσεις',
+ 'settings.apiScopes.accommodations': 'Διαμονή',
+ 'settings.apiScopes.travellers': 'Ποιοι συμμετέχουν',
+ 'settings.apiScopes.bucket-list': 'Λίστα επιθυμιών',
+ 'settings.apiScopes.stats': 'Σύνολα',
'settings.apiKeys.title': 'Κλειδιά API',
'settings.apiKeys.description': 'Κλειδιά για το δημόσιο API, ώστε άλλο λογισμικό να μπορεί να διαβάζει τα ταξίδια σου. Μόνο για ανάγνωση: ένα κλειδί δεν μπορεί να αλλάξει ή να διαγράψει τίποτα.',
'settings.apiKeys.create': 'Δημιουργία κλειδιού',
diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts
index c29d73194..795a91ea1 100644
--- a/shared/src/i18n/hu/admin.ts
+++ b/shared/src/i18n/hu/admin.ts
@@ -616,6 +616,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Helyek importálása megosztott Naver Maps-listából',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Járatok szinkronizálása az AirTrail-példányodból',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Tartózkodások és rögzített útvonalak olvasása egy Dawarich-példányból, amelyet minden olvasó maga köt be',
'admin.addons.catalog.llm_parsing.name': 'MI-elemzés',
'admin.addons.catalog.llm_parsing.description': 'Beolvassa azokat a foglalásokat, amelyeket a beépített értelmező nem tud, az általad választott MI-modellel',
'admin.addons.enabled': 'Engedélyezve',
diff --git a/shared/src/i18n/hu/dawarich.ts b/shared/src/i18n/hu/dawarich.ts
new file mode 100644
index 000000000..a4d9c0269
--- /dev/null
+++ b/shared/src/i18n/hu/dawarich.ts
@@ -0,0 +1,181 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Kapcsold össze a saját Dawarich-példányodat, és lásd, merre jártál valójában. A TREK kiolvassa, és naplóbejegyzéseket, helyeket és országokat javasol — semmi nem kerül be, amíg meg nem erősíted, és a Dawarichba semmit nem ír vissza.',
+ 'dawarich.url': 'A példány címe',
+ 'dawarich.apiKey': 'API-kulcs',
+ 'dawarich.apiKeyPlaceholder': 'Illeszd be a Dawarich API-kulcsodat',
+ 'dawarich.apiKeyHint':
+ 'A Dawarichban a Fiók → API-kulcs alatt található. Titkosítva tároljuk, és többé nem jelenik meg.',
+ 'dawarich.allowInsecureTls': 'Saját aláírású tanúsítvány engedélyezése',
+ 'dawarich.allowInsecureTlsHint':
+ 'Csak akkor kell, ha a példányod olyan tanúsítványt használ, amelyben a kiszolgálód nem bízik meg.',
+ 'dawarich.syncEnabled': 'Új tartózkodások automatikus keresése',
+ 'dawarich.syncEnabledHint': 'Kikapcsolva a TREK csak akkor olvassa a Dawarichot, ha te kéred.',
+ 'dawarich.test.button': 'Kapcsolat tesztelése',
+ 'dawarich.test.success': 'Kapcsolódva. {count} tartózkodás az elmúlt 30 napban.',
+ 'dawarich.test.failed': 'Nem sikerült elérni a Dawarichot.',
+ 'dawarich.syncNow': 'Keresés most',
+ 'dawarich.connected': 'Kapcsolódva',
+ 'dawarich.notConnected': 'Nincs kapcsolódva',
+ 'dawarich.disconnect': 'Leválasztás',
+ 'dawarich.lastSync': 'Utoljára ellenőrizve: {when}',
+ 'dawarich.neverSynced': 'Még nem volt ellenőrzés',
+ 'dawarich.syncPartial': 'néhány utat nem sikerült kiolvasni',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich-kapcsolat mentve',
+ 'dawarich.toast.saveError': 'Nem sikerült menteni a kapcsolatot',
+ 'dawarich.toast.disconnected': 'Dawarich leválasztva',
+ 'dawarich.toast.synced': '{count} új tartózkodás',
+ 'dawarich.toast.syncError': 'Nem sikerült kiolvasni a Dawarichot',
+ 'dawarich.toast.syncRunning': 'Már fut egy ellenőrzés',
+ 'dawarich.toast.acceptError': 'Nem sikerült hozzáadni',
+ 'dawarich.toast.updateError': 'Nem sikerült frissíteni ezt a javaslatot',
+ 'dawarich.toast.accepted.place': 'Hozzáadva az úthoz',
+ 'dawarich.toast.accepted.journal': 'Hozzáadva a naplóhoz',
+ 'dawarich.toast.accepted.bucket_list': 'Kipipálva a kívánságlistádon',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'tartózkodások',
+ 'dawarich.capability.track': 'rögzített útvonal',
+ 'dawarich.capability.locations': 'kívánságlista-egyeztetés',
+ 'dawarich.capability.visitedCities': 'országok és városok',
+ 'dawarich.capability.missing': 'Ez a Dawarich-verzió nem kínálja a következőket: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'A TREK nem érte el ezt a címet.',
+ 'dawarich.error.unauthorized': 'A Dawarich elutasította az API-kulcsot.',
+ 'dawarich.error.forbidden': 'Ez az API-kulcs nem olvashatja ezt.',
+ 'dawarich.error.not_found': 'Ebben a Dawarich-verzióban nincs ilyen végpont.',
+ 'dawarich.error.rate_limited': 'A Dawarich lassítást kért a TREK-től. Próbáld újra hamarosan.',
+ 'dawarich.error.server_error': 'A Dawarich hibával válaszolt.',
+ 'dawarich.error.invalid_response': 'Ez a cím olyasmivel válaszolt, ami nem Dawarich.',
+ 'dawarich.error.too_large': 'A Dawarich több adatot küldött, mint amennyit a TREK egyszerre beolvas.',
+ 'dawarich.error.not_connected': 'Még nincs csatlakoztatott Dawarich-példány.',
+ 'dawarich.error.addon_disabled': 'A Dawarich bővítmény ki van kapcsolva ezen a példányon.',
+ 'dawarich.error.offline': 'Ehhez kapcsolat kell — a TREK most offline.',
+ 'dawarich.error.invalid_url': 'A TREK nem tudja használni ezt a címet.',
+ 'dawarich.warning.private_ip': 'Ez a cím privát IP-re mutat ({ip}). Ellenőrizd, hogy így akartad-e — a szervernek ehhez ALLOW_INTERNAL_NETWORK=true kellhet.',
+ 'dawarich.error.unknown': 'Hiba történt a Dawarichhal folytatott kommunikáció közben.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Rögzített útvonal megjelenítése',
+ 'dawarich.trail.hide': 'Rögzített útvonal elrejtése',
+ 'dawarich.trail.loading': 'Rögzített útvonal betöltése…',
+ 'dawarich.trail.empty': 'Ezeken a napokon nem készült rögzítés',
+ 'dawarich.trail.offline': 'A rögzített útvonalhoz kapcsolat kell',
+ 'dawarich.trail.unavailable': 'A rögzített útvonalat nem sikerült betölteni',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} perc',
+ 'dawarich.duration.hours': '{hours} ó',
+ 'dawarich.duration.hoursMinutes': '{hours} ó {minutes} perc',
+ 'dawarich.checkedAgo': 'ellenőrizve {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Bizonytalan',
+ 'dawarich.badge.sourceChanged': 'Megváltozott a Dawarichban',
+ 'dawarich.badge.sourceMissing': 'Eltűnt a Dawarichból',
+
+ 'dawarich.suggestions.title': 'A Dawarichból',
+ 'dawarich.suggestions.pending': '{count} vár rád',
+ 'dawarich.suggestions.loading': 'A Dawarich olvasása…',
+ 'dawarich.suggestions.notConnected':
+ 'Kapcsold össze a Dawarichot a Beállításokban, hogy itt lásd a tartózkodásaidat.',
+ 'dawarich.suggestions.unavailable': 'A Dawarichot nem sikerült kiolvasni.',
+ 'dawarich.suggestions.allHandled': 'Minden itt rögzített dologgal foglalkoztál már.',
+ 'dawarich.suggestions.asJournal': 'Naplóbejegyzés írása',
+ 'dawarich.suggestions.asPlace': 'Hozzáadás helyszínként',
+ 'dawarich.suggestions.dismiss': 'Itt nem jártam',
+ 'dawarich.suggestions.dismissed': 'Elvetve',
+ 'dawarich.suggestions.restore': 'Visszatétel',
+ 'dawarich.suggestions.showHandled': '{count} elintézett megjelenítése',
+ 'dawarich.suggestions.hideHandled': 'A már elintézettek elrejtése',
+ 'dawarich.suggestions.matchesWish': 'A kívánságlistádon: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Hozzáadva helyszínként',
+ 'dawarich.suggestions.acceptedAs.journal': 'A naplóban',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Kívánság kipipálva',
+ 'dawarich.suggestions.sourceChanged':
+ 'Ez a tartózkodás megváltozott a Dawarichban, amióta felhasználtad. Amit a TREK-ben írtál, érintetlen maradt.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Ez a tartózkodás már nem létezik a Dawarichban. Amit a TREK-ben írtál, érintetlen maradt.',
+ 'dawarich.sourceStatus.suggested': 'Felismerve, megerősítetlen',
+ 'dawarich.confidence.high': 'Biztos felismerés',
+ 'dawarich.confidence.medium': 'Meglehetősen biztos felismerés',
+ 'dawarich.confidence.low': 'Bizonytalan felismerés',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Tartózkodás hozzáadása helyszínként',
+ 'dawarich.accept.title.journal': 'Naplóbejegyzés írása',
+ 'dawarich.accept.title.bucket_list': 'Kívánság kipipálása',
+ 'dawarich.accept.confirm.place': 'Helyszín hozzáadása',
+ 'dawarich.accept.confirm.journal': 'Bejegyzés hozzáadása',
+ 'dawarich.accept.confirm.bucket_list': 'Pipáld ki',
+ 'dawarich.accept.recorded': 'Rögzítve {from} és {to} között',
+ 'dawarich.accept.duration': '{minutes} perc',
+ 'dawarich.accept.name': 'Név',
+ 'dawarich.accept.date': 'Dátum',
+ 'dawarich.accept.from': 'Érkezés',
+ 'dawarich.accept.to': 'Távozás',
+ 'dawarich.accept.trip': 'Utazás',
+ 'dawarich.accept.thisTrip': 'Ez az utazás',
+ 'dawarich.accept.pickTrip': 'Válassz utazást',
+ 'dawarich.accept.day': 'Nap',
+ 'dawarich.accept.noDay': 'Még nincs naphoz rendelve',
+ 'dawarich.accept.journal': 'Napló',
+ 'dawarich.accept.pickJournal': 'Válassz naplót',
+ 'dawarich.accept.notes': 'Jegyzetek',
+ 'dawarich.accept.story': 'A történeted',
+ 'dawarich.accept.storyPlaceholder': 'Mi történt itt?',
+ 'dawarich.accept.photosHint': 'A fotókat a bejegyzés létrehozása után add hozzá.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'A Dawarich-felvételeidből hozzáadva',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Kívánságlista összevetése a Dawarichcsal',
+ 'dawarich.bucket.description':
+ 'Átnézi a rögzítéseidet azokat a helyeket keresve, amelyeket el akartál érni. Egy látogatáshoz közelség és eltöltött idő is kell — az elhajtás nem számít.',
+ 'dawarich.bucket.scan': 'Kívánságlista ellenőrzése',
+ 'dawarich.bucket.scanning': 'Ellenőrzés…',
+ 'dawarich.bucket.noMatches': 'A kívánságlistádról semmi nem bukkant fel a rögzítéseidben.',
+ 'dawarich.bucket.alreadyVisited': 'Már kipipálva',
+ 'dawarich.bucket.confirm': '{count} kipipálása',
+ 'dawarich.bucket.confirmed': '{count} kívánság kipipálva',
+ 'dawarich.bucket.skipped': '{count} bejegyzésnek nincs koordinátája, ezért nem volt ellenőrizhető.',
+ 'dawarich.bucket.truncated': 'Csak az első bejegyzések lettek ellenőrizve. Futtasd le újra a többihez.',
+ 'dawarich.bucket.visitedFrom': 'A Dawarich-rögzítéseid alapján kipipálva',
+ 'dawarich.bucket.clearVisit': 'Visszavonás',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Országok a Dawarichból',
+ 'dawarich.atlas.description':
+ 'Azok az országok, amelyekben a rögzítéseid szerint jártál. Erősítsd meg, melyek kerüljenek az Atlaszodba — magától semmi nem kerül be, és amit kézzel jelöltél, a tiéd marad.',
+ 'dawarich.atlas.load': 'Országok keresése',
+ 'dawarich.atlas.loading': 'A rögzítéseid olvasása…',
+ 'dawarich.atlas.empty': 'A rögzítéseid nem mutatnak olyan országot, amelyet a TREK ne ismerne már.',
+ 'dawarich.atlas.cities': '{count} város',
+ 'dawarich.atlas.citiesOne': '1 város',
+ 'dawarich.atlas.accept': '{count} ország hozzáadása',
+ 'dawarich.atlas.accepted': '{count} ország hozzáadva',
+ 'dawarich.atlas.unresolved': 'A TREK ezeket nem tudta országhoz párosítani: {names}.',
+ 'dawarich.atlas.source': 'A Dawarichból',
+ 'dawarich.atlas.range': 'Vizsgált időszak: {from} – {to}',
+
+ 'dawarich.atlas.trigger': 'Kívánságok és országok a felvételeidből',
+ 'dawarich.atlas.dialogSubtitle': 'Mit mondanak a felvételeid az Atlaszodról',
+ 'dawarich.atlas.tab.wishes': 'Kívánságlista',
+ 'dawarich.atlas.tab.countries': 'Országok',
+ 'dawarich.atlas.window': 'Az elmúlt 12 hónapot néztük át.',
+ 'dawarich.selected': '{count} kiválasztva',
+ 'dawarich.again': 'Ellenőrzés újra',
+ 'dawarich.bucket.metersAway': '{meters} m-re',
+ 'dawarich.bucket.kilometersAway': '{km} km-re',
+ 'dawarich.bucket.rule': 'Egy kívánság {meters} méteren belül és {minutes} perc helyszíni idő után számít teljesítettnek.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts
index 1bb07ab5e..66df25441 100644
--- a/shared/src/i18n/hu/index.ts
+++ b/shared/src/i18n/hu/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts
index 16054699d..75d63aaca 100644
--- a/shared/src/i18n/hu/settings.ts
+++ b/shared/src/i18n/hu/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Automatikusan megjeleníti minden repülőjárat, vonat és egyéb foglalás útvonalát a térképen, nincs szükség egyenkénti bekapcsolásra.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Mit olvashat ez a kulcs',
+ 'settings.apiScopes.hint':
+ 'Hagyj mindent bekapcsolva, ha a kulcs mindent láthat. Amit kikapcsolsz, azt a rendszer megtagadja ettől a kulcstól, nem csak kihagyja a válaszból.',
+ 'settings.apiScopes.all': 'Minden',
+ 'settings.apiScopes.limited': '{count} / {total}',
+ 'settings.apiScopes.trips': 'Utazások',
+ 'settings.apiScopes.days': 'Napok',
+ 'settings.apiScopes.places': 'Helyszínek',
+ 'settings.apiScopes.notes': 'Napi jegyzetek',
+ 'settings.apiScopes.reservations': 'Foglalások',
+ 'settings.apiScopes.accommodations': 'Szállás',
+ 'settings.apiScopes.travellers': 'Kik utaznak',
+ 'settings.apiScopes.bucket-list': 'Kívánságlista',
+ 'settings.apiScopes.stats': 'Összesítések',
'settings.apiKeys.title': 'API-kulcsok',
'settings.apiKeys.description': 'Kulcsok a nyilvános API-hoz, hogy más szoftverek olvashassák az utazásaidat. Csak olvasható: a kulcs semmit nem módosít és nem töröl.',
'settings.apiKeys.create': 'Kulcs létrehozása',
diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts
index 83bcf7017..c1d3e3ec9 100644
--- a/shared/src/i18n/id/admin.ts
+++ b/shared/src/i18n/id/admin.ts
@@ -657,6 +657,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Impor tempat dari daftar Naver Maps yang dibagikan',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sinkronkan penerbangan dari instans AirTrail milikmu',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Baca kunjungan dan rute terekam dari instans Dawarich yang dihubungkan sendiri oleh tiap pembaca',
'admin.addons.catalog.llm_parsing.name': 'Analisis AI',
'admin.addons.catalog.llm_parsing.description': 'Membaca pemesanan yang tidak dipahami pengurai bawaan, dengan model AI pilihanmu',
'admin.addons.enabled': 'Aktif',
diff --git a/shared/src/i18n/id/dawarich.ts b/shared/src/i18n/id/dawarich.ts
new file mode 100644
index 000000000..3c3fbd380
--- /dev/null
+++ b/shared/src/i18n/id/dawarich.ts
@@ -0,0 +1,180 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Hubungkan instans Dawarich milikmu sendiri untuk melihat ke mana kamu benar-benar pergi. TREK membacanya dan mengusulkan entri jurnal, tempat, dan negara — tidak ada yang ditambahkan sebelum kamu mengonfirmasinya, dan tidak ada yang ditulis kembali ke Dawarich.',
+ 'dawarich.url': 'Alamat instans',
+ 'dawarich.apiKey': 'Kunci API',
+ 'dawarich.apiKeyPlaceholder': 'Tempelkan kunci API Dawarich milikmu',
+ 'dawarich.apiKeyHint':
+ 'Ada di Dawarich pada Account → API key. Disimpan terenkripsi dan tidak pernah ditampilkan lagi.',
+ 'dawarich.allowInsecureTls': 'Izinkan sertifikat yang ditandatangani sendiri',
+ 'dawarich.allowInsecureTlsHint':
+ 'Hanya perlu jika instansmu memakai sertifikat yang tidak dipercaya oleh servermu.',
+ 'dawarich.syncEnabled': 'Periksa persinggahan baru secara otomatis',
+ 'dawarich.syncEnabledHint': 'Jika mati, TREK hanya membaca Dawarich saat kamu memintanya.',
+ 'dawarich.test.button': 'Uji koneksi',
+ 'dawarich.test.success': 'Terhubung. {count} persinggahan ditemukan dalam 30 hari terakhir.',
+ 'dawarich.test.failed': 'Tidak dapat menjangkau Dawarich.',
+ 'dawarich.syncNow': 'Periksa sekarang',
+ 'dawarich.connected': 'Terhubung',
+ 'dawarich.notConnected': 'Tidak terhubung',
+ 'dawarich.disconnect': 'Putuskan',
+ 'dawarich.lastSync': 'Terakhir diperiksa {when}',
+ 'dawarich.neverSynced': 'Belum pernah diperiksa',
+ 'dawarich.syncPartial': 'beberapa perjalanan tidak dapat dibaca',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Koneksi Dawarich disimpan',
+ 'dawarich.toast.saveError': 'Tidak dapat menyimpan koneksi',
+ 'dawarich.toast.disconnected': 'Dawarich diputuskan',
+ 'dawarich.toast.synced': '{count} persinggahan baru ditemukan',
+ 'dawarich.toast.syncError': 'Tidak dapat membaca Dawarich',
+ 'dawarich.toast.syncRunning': 'Pemeriksaan sedang berjalan',
+ 'dawarich.toast.acceptError': 'Tidak dapat menambahkan ini',
+ 'dawarich.toast.updateError': 'Tidak dapat memperbarui saran ini',
+ 'dawarich.toast.accepted.place': 'Ditambahkan ke perjalanan',
+ 'dawarich.toast.accepted.journal': 'Ditambahkan ke jurnal',
+ 'dawarich.toast.accepted.bucket_list': 'Dicoret dari daftar keinginanmu',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'persinggahan',
+ 'dawarich.capability.track': 'rute terekam',
+ 'dawarich.capability.locations': 'pencocokan daftar keinginan',
+ 'dawarich.capability.visitedCities': 'negara dan kota',
+ 'dawarich.capability.missing': 'Versi Dawarich ini tidak menyediakan: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK tidak dapat menjangkau alamat itu.',
+ 'dawarich.error.unauthorized': 'Dawarich menolak kunci API tersebut.',
+ 'dawarich.error.forbidden': 'Kunci API itu tidak diizinkan membaca ini.',
+ 'dawarich.error.not_found': 'Versi Dawarich ini tidak punya endpoint tersebut.',
+ 'dawarich.error.rate_limited': 'Dawarich meminta TREK memperlambat. Coba lagi sebentar lagi.',
+ 'dawarich.error.server_error': 'Dawarich menjawab dengan galat.',
+ 'dawarich.error.invalid_response': 'Alamat itu menjawab dengan sesuatu yang bukan Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich mengirim data lebih banyak daripada yang dibaca TREK sekaligus.',
+ 'dawarich.error.not_connected': 'Belum ada instans Dawarich yang terhubung.',
+ 'dawarich.error.addon_disabled': 'Addon Dawarich dimatikan untuk instans ini.',
+ 'dawarich.error.offline': 'Ini butuh koneksi — TREK sedang offline.',
+ 'dawarich.error.invalid_url': 'TREK tidak bisa memakai alamat itu.',
+ 'dawarich.warning.private_ip': 'Alamat itu mengarah ke IP privat ({ip}). Pastikan memang itu maksudmu — server mungkin perlu ALLOW_INTERNAL_NETWORK=true untuk menjangkaunya.',
+ 'dawarich.error.unknown': 'Terjadi kesalahan saat berkomunikasi dengan Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Tampilkan rute terekam',
+ 'dawarich.trail.hide': 'Sembunyikan rute terekam',
+ 'dawarich.trail.loading': 'Memuat rute terekam…',
+ 'dawarich.trail.empty': 'Tidak ada yang terekam pada tanggal ini',
+ 'dawarich.trail.offline': 'Rute terekam memerlukan koneksi',
+ 'dawarich.trail.unavailable': 'Rute terekam tidak dapat dimuat',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} mnt',
+ 'dawarich.duration.hours': '{hours} j',
+ 'dawarich.duration.hoursMinutes': '{hours} j {minutes} mnt',
+ 'dawarich.checkedAgo': 'diperiksa {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Ragu',
+ 'dawarich.badge.sourceChanged': 'Berubah di Dawarich',
+ 'dawarich.badge.sourceMissing': 'Hilang dari Dawarich',
+
+ 'dawarich.suggestions.title': 'Dari Dawarich',
+ 'dawarich.suggestions.pending': '{count} menunggumu',
+ 'dawarich.suggestions.loading': 'Membaca Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Hubungkan Dawarich di Pengaturan untuk melihat persinggahanmu di sini.',
+ 'dawarich.suggestions.unavailable': 'Dawarich tidak dapat dibaca.',
+ 'dawarich.suggestions.allHandled': 'Semua yang terekam di sini sudah ditangani.',
+ 'dawarich.suggestions.asJournal': 'Tulis entri jurnal',
+ 'dawarich.suggestions.asPlace': 'Tambahkan sebagai tempat',
+ 'dawarich.suggestions.dismiss': 'Bukan tempat yang saya kunjungi',
+ 'dawarich.suggestions.dismissed': 'Diabaikan',
+ 'dawarich.suggestions.restore': 'Kembalikan',
+ 'dawarich.suggestions.showHandled': 'Tampilkan {count} yang sudah ditangani',
+ 'dawarich.suggestions.hideHandled': 'Sembunyikan yang sudah ditangani',
+ 'dawarich.suggestions.matchesWish': 'Ada di daftar keinginanmu: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Ditambahkan sebagai tempat',
+ 'dawarich.suggestions.acceptedAs.journal': 'Ada di jurnal',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Keinginan dicoret',
+ 'dawarich.suggestions.sourceChanged':
+ 'Persinggahan ini berubah di Dawarich sejak kamu memakainya. Apa yang kamu tulis di TREK tetap utuh.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Persinggahan ini tidak ada lagi di Dawarich. Apa yang kamu tulis di TREK tetap utuh.',
+ 'dawarich.sourceStatus.suggested': 'Terdeteksi, belum dikonfirmasi',
+ 'dawarich.confidence.high': 'Deteksi meyakinkan',
+ 'dawarich.confidence.medium': 'Deteksi cukup meyakinkan',
+ 'dawarich.confidence.low': 'Deteksi tidak pasti',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Tambahkan persinggahan ini sebagai tempat',
+ 'dawarich.accept.title.journal': 'Tulis entri jurnal',
+ 'dawarich.accept.title.bucket_list': 'Coret sebuah keinginan',
+ 'dawarich.accept.confirm.place': 'Tambah tempat',
+ 'dawarich.accept.confirm.journal': 'Tambah entri',
+ 'dawarich.accept.confirm.bucket_list': 'Coret',
+ 'dawarich.accept.recorded': 'Terekam {from} sampai {to}',
+ 'dawarich.accept.duration': '{minutes} mnt',
+ 'dawarich.accept.name': 'Nama',
+ 'dawarich.accept.date': 'Tanggal',
+ 'dawarich.accept.from': 'Tiba',
+ 'dawarich.accept.to': 'Pergi',
+ 'dawarich.accept.trip': 'Perjalanan',
+ 'dawarich.accept.thisTrip': 'Perjalanan ini',
+ 'dawarich.accept.pickTrip': 'Pilih perjalanan',
+ 'dawarich.accept.day': 'Hari',
+ 'dawarich.accept.noDay': 'Belum pada suatu hari',
+ 'dawarich.accept.journal': 'Jurnal',
+ 'dawarich.accept.pickJournal': 'Pilih jurnal',
+ 'dawarich.accept.notes': 'Catatan',
+ 'dawarich.accept.story': 'Kisahmu',
+ 'dawarich.accept.storyPlaceholder': 'Apa yang terjadi di sini?',
+ 'dawarich.accept.photosHint': 'Tambahkan foto ke entri setelah entri dibuat.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Ditambahkan dari rekaman Dawarich-mu',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Cocokkan daftar keinginanmu dengan Dawarich',
+ 'dawarich.bucket.description':
+ 'Menelusuri rekamanmu untuk mencari tempat-tempat yang ingin kamu capai. Sebuah kunjungan butuh kedekatan sekaligus waktu yang dihabiskan — sekadar lewat tidak dihitung.',
+ 'dawarich.bucket.scan': 'Periksa daftar keinginan',
+ 'dawarich.bucket.scanning': 'Memeriksa…',
+ 'dawarich.bucket.noMatches': 'Tidak ada isi daftar keinginanmu yang muncul di rekamanmu.',
+ 'dawarich.bucket.alreadyVisited': 'Sudah dicoret',
+ 'dawarich.bucket.confirm': 'Coret {count}',
+ 'dawarich.bucket.confirmed': '{count} keinginan dicoret',
+ 'dawarich.bucket.skipped': '{count} entri tidak punya koordinat dan tidak dapat diperiksa.',
+ 'dawarich.bucket.truncated': 'Hanya entri pertama yang diperiksa. Jalankan lagi untuk sisanya.',
+ 'dawarich.bucket.visitedFrom': 'Dicoret dari rekaman Dawarich milikmu',
+ 'dawarich.bucket.clearVisit': 'Batalkan',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Negara dari Dawarich',
+ 'dawarich.atlas.description':
+ 'Negara-negara yang menurut rekamanmu pernah kamu singgahi. Konfirmasi mana yang kamu inginkan di Atlas-mu — tidak ada yang ditambahkan dengan sendirinya, dan yang kamu tandai sendiri tetap milikmu.',
+ 'dawarich.atlas.load': 'Cari negara',
+ 'dawarich.atlas.loading': 'Membaca rekamanmu…',
+ 'dawarich.atlas.empty': 'Rekamanmu tidak menunjukkan negara yang belum dimiliki TREK.',
+ 'dawarich.atlas.cities': '{count} kota',
+ 'dawarich.atlas.citiesOne': '1 kota',
+ 'dawarich.atlas.accept': 'Tambah {count} negara',
+ 'dawarich.atlas.accepted': '{count} negara ditambahkan',
+ 'dawarich.atlas.unresolved': 'TREK tidak dapat mencocokkan ini dengan sebuah negara: {names}.',
+ 'dawarich.atlas.source': 'Dari Dawarich',
+ 'dawarich.atlas.range': 'Ditinjau dari {from} sampai {to}',
+
+ 'dawarich.atlas.trigger': 'Keinginan dan negara dari rekamanmu',
+ 'dawarich.atlas.dialogSubtitle': 'Apa kata rekamanmu tentang Atlas-mu',
+ 'dawarich.atlas.tab.wishes': 'Daftar keinginan',
+ 'dawarich.atlas.tab.countries': 'Negara',
+ 'dawarich.atlas.window': 'Yang diperiksa adalah 12 bulan terakhir.',
+ 'dawarich.selected': '{count} dipilih',
+ 'dawarich.again': 'Periksa lagi',
+ 'dawarich.bucket.metersAway': '{meters} m dari sana',
+ 'dawarich.bucket.kilometersAway': '{km} km dari sana',
+ 'dawarich.bucket.rule': 'Sebuah keinginan dianggap tercapai dalam radius {meters} m dan setelah {minutes} menit di lokasi.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/id/index.ts
+++ b/shared/src/i18n/id/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts
index d955567d9..489b34f48 100644
--- a/shared/src/i18n/id/settings.ts
+++ b/shared/src/i18n/id/settings.ts
@@ -550,6 +550,21 @@ const settings: TranslationStrings = {
'Menampilkan rute setiap penerbangan, kereta, dan pemesanan lainnya di peta secara otomatis, tanpa perlu mengaktifkannya satu per satu.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Apa yang boleh dibaca kunci ini',
+ 'settings.apiScopes.hint':
+ 'Biarkan semuanya menyala untuk kunci yang boleh melihat semuanya. Apa pun yang kamu matikan akan ditolak untuk kunci ini, bukan sekadar tidak disertakan dalam jawaban.',
+ 'settings.apiScopes.all': 'Semuanya',
+ 'settings.apiScopes.limited': '{count} dari {total}',
+ 'settings.apiScopes.trips': 'Perjalanan',
+ 'settings.apiScopes.days': 'Hari',
+ 'settings.apiScopes.places': 'Tempat',
+ 'settings.apiScopes.notes': 'Catatan harian',
+ 'settings.apiScopes.reservations': 'Pemesanan',
+ 'settings.apiScopes.accommodations': 'Akomodasi',
+ 'settings.apiScopes.travellers': 'Siapa yang ikut',
+ 'settings.apiScopes.bucket-list': 'Daftar keinginan',
+ 'settings.apiScopes.stats': 'Total',
'settings.apiKeys.title': 'Kunci API',
'settings.apiKeys.description': 'Kunci untuk API publik, agar perangkat lunak lain dapat membaca perjalananmu. Hanya baca: kunci tidak dapat mengubah atau menghapus apa pun.',
'settings.apiKeys.create': 'Buat kunci',
diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts
index d92d7ae4e..ca95d58bf 100644
--- a/shared/src/i18n/it/admin.ts
+++ b/shared/src/i18n/it/admin.ts
@@ -612,6 +612,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importa luoghi da un elenco condiviso di Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Sincronizza i voli dalla tua istanza AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Legge le soste e i percorsi registrati da un’istanza Dawarich che ogni utente collega da sé',
'admin.addons.catalog.llm_parsing.name': 'Analisi con IA',
'admin.addons.catalog.llm_parsing.description': 'Legge le prenotazioni che l’analizzatore integrato non riesce a interpretare, con un modello di IA a tua scelta',
'admin.addons.enabled': 'Abilitato',
diff --git a/shared/src/i18n/it/dawarich.ts b/shared/src/i18n/it/dawarich.ts
new file mode 100644
index 000000000..7194634df
--- /dev/null
+++ b/shared/src/i18n/it/dawarich.ts
@@ -0,0 +1,182 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Collega la tua istanza Dawarich per vedere dove sei stato davvero. TREK la legge e propone voci di diario, luoghi e paesi — nulla viene aggiunto finché non lo confermi e nulla viene riscritto su Dawarich.',
+ 'dawarich.url': 'Indirizzo dell’istanza',
+ 'dawarich.apiKey': 'Chiave API',
+ 'dawarich.apiKeyPlaceholder': 'Incolla la tua chiave API di Dawarich',
+ 'dawarich.apiKeyHint':
+ 'La trovi in Dawarich sotto Account → Chiave API. Viene salvata cifrata e non viene più mostrata.',
+ 'dawarich.allowInsecureTls': 'Consenti certificato autofirmato',
+ 'dawarich.allowInsecureTlsHint':
+ 'Serve solo se la tua istanza usa un certificato di cui il tuo server non si fida.',
+ 'dawarich.syncEnabled': 'Cerca automaticamente nuove soste',
+ 'dawarich.syncEnabledHint': 'Se disattivato, TREK legge Dawarich solo quando glielo chiedi.',
+ 'dawarich.test.button': 'Prova la connessione',
+ 'dawarich.test.success': 'Connesso. {count} soste trovate negli ultimi 30 giorni.',
+ 'dawarich.test.failed': 'Impossibile raggiungere Dawarich.',
+ 'dawarich.syncNow': 'Controlla ora',
+ 'dawarich.connected': 'Connesso',
+ 'dawarich.notConnected': 'Non connesso',
+ 'dawarich.disconnect': 'Disconnetti',
+ 'dawarich.lastSync': 'Ultimo controllo {when}',
+ 'dawarich.neverSynced': 'Non ancora controllato',
+ 'dawarich.syncPartial': 'alcuni viaggi non sono stati letti',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Connessione a Dawarich salvata',
+ 'dawarich.toast.saveError': 'Impossibile salvare la connessione',
+ 'dawarich.toast.disconnected': 'Dawarich disconnesso',
+ 'dawarich.toast.synced': '{count} nuove soste trovate',
+ 'dawarich.toast.syncError': 'Impossibile leggere Dawarich',
+ 'dawarich.toast.syncRunning': 'Un controllo è già in corso',
+ 'dawarich.toast.acceptError': 'Impossibile aggiungerlo',
+ 'dawarich.toast.updateError': 'Impossibile aggiornare questo suggerimento',
+ 'dawarich.toast.accepted.place': 'Aggiunto al viaggio',
+ 'dawarich.toast.accepted.journal': 'Aggiunto al diario',
+ 'dawarich.toast.accepted.bucket_list': 'Spuntato dalla tua lista dei desideri',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'soste',
+ 'dawarich.capability.track': 'percorso registrato',
+ 'dawarich.capability.locations': 'confronto con la lista dei desideri',
+ 'dawarich.capability.visitedCities': 'paesi e città',
+ 'dawarich.capability.missing': 'Questa versione di Dawarich non offre: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK non è riuscito a raggiungere quell’indirizzo.',
+ 'dawarich.error.unauthorized': 'Dawarich ha rifiutato la chiave API.',
+ 'dawarich.error.forbidden': 'Quella chiave API non è autorizzata a leggere questi dati.',
+ 'dawarich.error.not_found': 'Questa versione di Dawarich non ha quell’endpoint.',
+ 'dawarich.error.rate_limited': 'Dawarich ha chiesto a TREK di rallentare. Riprova tra poco.',
+ 'dawarich.error.server_error': 'Dawarich ha risposto con un errore.',
+ 'dawarich.error.invalid_response': 'Quell’indirizzo ha risposto con qualcosa che non è Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich ha inviato più dati di quanti TREK ne legga in una volta.',
+ 'dawarich.error.not_connected': 'Nessuna istanza Dawarich è ancora collegata.',
+ 'dawarich.error.addon_disabled': 'Il componente aggiuntivo Dawarich è disattivato su questa istanza.',
+ 'dawarich.error.offline': 'Serve una connessione: TREK al momento è offline.',
+ 'dawarich.error.invalid_url': 'TREK non può usare questo indirizzo.',
+ 'dawarich.warning.private_ip': 'Questo indirizzo punta a un IP privato ({ip}). Verifica che sia voluto: il server potrebbe richiedere ALLOW_INTERNAL_NETWORK=true per raggiungerlo.',
+ 'dawarich.error.unknown': 'Qualcosa è andato storto nella comunicazione con Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Mostra il percorso registrato',
+ 'dawarich.trail.hide': 'Nascondi il percorso registrato',
+ 'dawarich.trail.loading': 'Caricamento del percorso registrato…',
+ 'dawarich.trail.empty': 'In queste date non è stato registrato nulla',
+ 'dawarich.trail.offline': 'Il percorso registrato richiede una connessione',
+ 'dawarich.trail.unavailable': 'Impossibile caricare il percorso registrato',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} h',
+ 'dawarich.duration.hoursMinutes': '{hours} h {minutes} min',
+ 'dawarich.checkedAgo': 'verificato {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Incerto',
+ 'dawarich.badge.sourceChanged': 'Cambiato in Dawarich',
+ 'dawarich.badge.sourceMissing': 'Sparito da Dawarich',
+
+ 'dawarich.suggestions.title': 'Da Dawarich',
+ 'dawarich.suggestions.pending': '{count} in attesa di te',
+ 'dawarich.suggestions.loading': 'Lettura di Dawarich…',
+ 'dawarich.suggestions.notConnected':
+ 'Collega Dawarich nelle Impostazioni per vedere qui le tue soste.',
+ 'dawarich.suggestions.unavailable': 'Impossibile leggere Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Tutto ciò che è registrato qui è già stato gestito.',
+ 'dawarich.suggestions.asJournal': 'Scrivi una voce di diario',
+ 'dawarich.suggestions.asPlace': 'Aggiungi come luogo',
+ 'dawarich.suggestions.dismiss': 'Non è un luogo che ho visitato',
+ 'dawarich.suggestions.dismissed': 'Ignorato',
+ 'dawarich.suggestions.restore': 'Ripristina',
+ 'dawarich.suggestions.showHandled': 'Mostra {count} già gestiti',
+ 'dawarich.suggestions.hideHandled': 'Nascondi quelli già gestiti',
+ 'dawarich.suggestions.matchesWish': 'Nella tua lista dei desideri: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Aggiunto come luogo',
+ 'dawarich.suggestions.acceptedAs.journal': 'Nel diario',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Desiderio spuntato',
+ 'dawarich.suggestions.sourceChanged':
+ 'Questa sosta è cambiata in Dawarich da quando l’hai usata. Quello che hai scritto in TREK resta intatto.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Questa sosta non esiste più in Dawarich. Quello che hai scritto in TREK resta intatto.',
+ 'dawarich.sourceStatus.suggested': 'Rilevata, non confermata',
+ 'dawarich.confidence.high': 'Rilevamento sicuro',
+ 'dawarich.confidence.medium': 'Rilevamento abbastanza sicuro',
+ 'dawarich.confidence.low': 'Rilevamento incerto',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Aggiungi questa sosta come luogo',
+ 'dawarich.accept.title.journal': 'Scrivi una voce di diario',
+ 'dawarich.accept.title.bucket_list': 'Spunta un desiderio',
+ 'dawarich.accept.confirm.place': 'Aggiungi luogo',
+ 'dawarich.accept.confirm.journal': 'Aggiungi voce',
+ 'dawarich.accept.confirm.bucket_list': 'Spuntalo',
+ 'dawarich.accept.recorded': 'Registrata dal {from} al {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nome',
+ 'dawarich.accept.date': 'Data',
+ 'dawarich.accept.from': 'Arrivo',
+ 'dawarich.accept.to': 'Partenza',
+ 'dawarich.accept.trip': 'Viaggio',
+ 'dawarich.accept.thisTrip': 'Questo viaggio',
+ 'dawarich.accept.pickTrip': 'Scegli un viaggio',
+ 'dawarich.accept.day': 'Giorno',
+ 'dawarich.accept.noDay': 'Non ancora assegnata a un giorno',
+ 'dawarich.accept.journal': 'Diario',
+ 'dawarich.accept.pickJournal': 'Scegli un diario',
+ 'dawarich.accept.notes': 'Note',
+ 'dawarich.accept.story': 'La tua storia',
+ 'dawarich.accept.storyPlaceholder': 'Cosa è successo qui?',
+ 'dawarich.accept.photosHint': 'Aggiungi le foto alla voce dopo averla creata.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Aggiunto dalle tue registrazioni Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Confronta la tua lista dei desideri con Dawarich',
+ 'dawarich.bucket.description':
+ 'Cerca nelle tue registrazioni i luoghi che volevi raggiungere. Una visita richiede sia la vicinanza sia il tempo trascorso — passarci davanti in auto non conta.',
+ 'dawarich.bucket.scan': 'Controlla la lista dei desideri',
+ 'dawarich.bucket.scanning': 'Controllo in corso…',
+ 'dawarich.bucket.noMatches': 'Nelle tue registrazioni non è emerso nulla della tua lista dei desideri.',
+ 'dawarich.bucket.alreadyVisited': 'Già spuntato',
+ 'dawarich.bucket.confirm': 'Spunta {count}',
+ 'dawarich.bucket.confirmed': '{count} desideri spuntati',
+ 'dawarich.bucket.skipped': '{count} voci non hanno coordinate e non sono state controllate.',
+ 'dawarich.bucket.truncated':
+ 'Sono state controllate solo le prime voci. Esegui di nuovo il controllo per le altre.',
+ 'dawarich.bucket.visitedFrom': 'Spuntato dalle tue registrazioni Dawarich',
+ 'dawarich.bucket.clearVisit': 'Annulla',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Paesi da Dawarich',
+ 'dawarich.atlas.description':
+ 'I paesi in cui, secondo le tue registrazioni, sei stato. Conferma quelli che vuoi nel tuo Atlante — nulla viene aggiunto da solo e ciò che hai segnato a mano resta tuo.',
+ 'dawarich.atlas.load': 'Cerca i paesi',
+ 'dawarich.atlas.loading': 'Lettura delle tue registrazioni…',
+ 'dawarich.atlas.empty': 'Le tue registrazioni non mostrano paesi che TREK non abbia già.',
+ 'dawarich.atlas.cities': '{count} città',
+ 'dawarich.atlas.citiesOne': '1 città',
+ 'dawarich.atlas.accept': 'Aggiungi {count} paesi',
+ 'dawarich.atlas.accepted': '{count} paesi aggiunti',
+ 'dawarich.atlas.unresolved': 'TREK non è riuscito ad abbinare questi a un paese: {names}.',
+ 'dawarich.atlas.source': 'Da Dawarich',
+ 'dawarich.atlas.range': 'Esaminato dal {from} al {to}',
+
+ 'dawarich.atlas.trigger': 'Desideri e paesi dalle tue registrazioni',
+ 'dawarich.atlas.dialogSubtitle': 'Cosa dicono le tue registrazioni sul tuo Atlas',
+ 'dawarich.atlas.tab.wishes': 'Lista dei desideri',
+ 'dawarich.atlas.tab.countries': 'Paesi',
+ 'dawarich.atlas.window': 'Sono stati esaminati gli ultimi 12 mesi.',
+ 'dawarich.selected': '{count} selezionati',
+ 'dawarich.again': 'Controlla di nuovo',
+ 'dawarich.bucket.metersAway': 'a {meters} m',
+ 'dawarich.bucket.kilometersAway': 'a {km} km',
+ 'dawarich.bucket.rule': 'Un desiderio è raggiunto entro {meters} m e dopo {minutes} minuti sul posto.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/it/index.ts
+++ b/shared/src/i18n/it/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts
index 585b92183..cb0263f36 100644
--- a/shared/src/i18n/it/settings.ts
+++ b/shared/src/i18n/it/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Mostra automaticamente sulla mappa il percorso di ogni volo, treno e altra prenotazione, senza doverlo attivare singolarmente.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Cosa può leggere questa chiave',
+ 'settings.apiScopes.hint':
+ 'Lascia tutto attivo per una chiave che deve vedere ogni cosa. Ciò che disattivi viene rifiutato per questa chiave, non semplicemente omesso dalla risposta.',
+ 'settings.apiScopes.all': 'Tutto',
+ 'settings.apiScopes.limited': '{count} di {total}',
+ 'settings.apiScopes.trips': 'Viaggi',
+ 'settings.apiScopes.days': 'Giorni',
+ 'settings.apiScopes.places': 'Luoghi',
+ 'settings.apiScopes.notes': 'Note del giorno',
+ 'settings.apiScopes.reservations': 'Prenotazioni',
+ 'settings.apiScopes.accommodations': 'Alloggio',
+ 'settings.apiScopes.travellers': 'Chi partecipa',
+ 'settings.apiScopes.bucket-list': 'Lista dei desideri',
+ 'settings.apiScopes.stats': 'Totali',
'settings.apiKeys.title': 'Chiavi API',
'settings.apiKeys.description': 'Chiavi per l\'API pubblica, così altri software possono leggere i tuoi viaggi. Sola lettura: una chiave non può modificare né eliminare nulla.',
'settings.apiKeys.create': 'Crea chiave',
diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts
index 154a5d73f..a11ce3502 100644
--- a/shared/src/i18n/ja/admin.ts
+++ b/shared/src/i18n/ja/admin.ts
@@ -621,6 +621,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': '共有された Naver マップのリストから場所をインポート',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': '自分の AirTrail から フライトを同期',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ '各自が接続した Dawarich インスタンスから、滞在と記録されたルートを読み取ります',
'admin.addons.catalog.llm_parsing.name': 'AI 解析',
'admin.addons.catalog.llm_parsing.description': '内蔵パーサーが読めない予約を、選んだ AI モデルで読み取ります',
'admin.addons.enabled': '有効',
diff --git a/shared/src/i18n/ja/dawarich.ts b/shared/src/i18n/ja/dawarich.ts
new file mode 100644
index 000000000..c14076f82
--- /dev/null
+++ b/shared/src/i18n/ja/dawarich.ts
@@ -0,0 +1,183 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ '自分の Dawarich インスタンスを接続すると、実際に訪れた場所がわかります。TREK はそれを読み取り、日記のエントリー、場所、国を提案します。確認するまで何も追加されず、Dawarich に書き戻すこともありません。',
+ 'dawarich.url': 'インスタンスのアドレス',
+ 'dawarich.apiKey': 'API キー',
+ 'dawarich.apiKeyPlaceholder': 'Dawarich の API キーを貼り付け',
+ 'dawarich.apiKeyHint':
+ 'Dawarich の「Account → API key」にあります。暗号化して保存され、以後は表示されません。',
+ 'dawarich.allowInsecureTls': '自己署名証明書を許可',
+ 'dawarich.allowInsecureTlsHint':
+ 'サーバーが信頼していない証明書をインスタンスが使っている場合にのみ必要です。',
+ 'dawarich.syncEnabled': '新しい滞在を自動で確認',
+ 'dawarich.syncEnabledHint': 'オフにすると、TREK は指示したときだけ Dawarich を読み取ります。',
+ 'dawarich.test.button': '接続をテスト',
+ 'dawarich.test.success': '接続しました。過去30日間で{count}件の滞在が見つかりました。',
+ 'dawarich.test.failed': 'Dawarich に接続できませんでした。',
+ 'dawarich.syncNow': '今すぐ確認',
+ 'dawarich.connected': '接続済み',
+ 'dawarich.notConnected': '未接続',
+ 'dawarich.disconnect': '接続解除',
+ 'dawarich.lastSync': '最終確認:{when}',
+ 'dawarich.neverSynced': 'まだ確認していません',
+ 'dawarich.syncPartial': '一部の旅行を読み取れませんでした',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich の接続を保存しました',
+ 'dawarich.toast.saveError': '接続を保存できませんでした',
+ 'dawarich.toast.disconnected': 'Dawarich の接続を解除しました',
+ 'dawarich.toast.synced': '{count}件の新しい滞在が見つかりました',
+ 'dawarich.toast.syncError': 'Dawarich を読み取れませんでした',
+ 'dawarich.toast.syncRunning': 'すでに確認中です',
+ 'dawarich.toast.acceptError': '追加できませんでした',
+ 'dawarich.toast.updateError': 'この提案を更新できませんでした',
+ 'dawarich.toast.accepted.place': '旅行に追加しました',
+ 'dawarich.toast.accepted.journal': '日記に追加しました',
+ 'dawarich.toast.accepted.bucket_list': 'ウィッシュリストにチェックを付けました',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': '滞在',
+ 'dawarich.capability.track': '記録されたルート',
+ 'dawarich.capability.locations': 'ウィッシュリストの照合',
+ 'dawarich.capability.visitedCities': '国と都市',
+ 'dawarich.capability.missing': 'この Dawarich のバージョンでは利用できません:{features}。',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK はそのアドレスに接続できませんでした。',
+ 'dawarich.error.unauthorized': 'Dawarich が API キーを拒否しました。',
+ 'dawarich.error.forbidden': 'その API キーにはこれを読み取る権限がありません。',
+ 'dawarich.error.not_found': 'この Dawarich のバージョンにはそのエンドポイントがありません。',
+ 'dawarich.error.rate_limited':
+ 'Dawarich からリクエストを控えるよう求められました。しばらくしてからお試しください。',
+ 'dawarich.error.server_error': 'Dawarich がエラーを返しました。',
+ 'dawarich.error.invalid_response': 'そのアドレスからの応答は Dawarich のものではありません。',
+ 'dawarich.error.too_large': 'Dawarich が、TREK が一度に読み取れる量を超えるデータを返しました。',
+ 'dawarich.error.not_connected': 'Dawarich インスタンスはまだ接続されていません。',
+ 'dawarich.error.addon_disabled': 'このインスタンスでは Dawarich アドオンが無効になっています。',
+ 'dawarich.error.offline': 'これには接続が必要です。TREK は現在オフラインです。',
+ 'dawarich.error.invalid_url': 'TREK はこのアドレスを使用できません。',
+ 'dawarich.warning.private_ip': 'このアドレスはプライベート IP ({ip}) を指しています。意図した設定かご確認ください。サーバー側に ALLOW_INTERNAL_NETWORK=true が必要な場合があります。',
+ 'dawarich.error.unknown': 'Dawarich との通信中に問題が発生しました。',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': '記録されたルートを表示',
+ 'dawarich.trail.hide': '記録されたルートを非表示',
+ 'dawarich.trail.loading': '記録されたルートを読み込み中…',
+ 'dawarich.trail.empty': 'この日付には記録がありません',
+ 'dawarich.trail.offline': '記録されたルートの表示には接続が必要です',
+ 'dawarich.trail.unavailable': '記録されたルートを読み込めませんでした',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes}分',
+ 'dawarich.duration.hours': '{hours}時間',
+ 'dawarich.duration.hoursMinutes': '{hours}時間{minutes}分',
+ 'dawarich.checkedAgo': '{ago}に確認',
+
+ 'dawarich.badge.lowConfidence': '不確か',
+ 'dawarich.badge.sourceChanged': 'Dawarich で変更',
+ 'dawarich.badge.sourceMissing': 'Dawarich から消失',
+
+ 'dawarich.suggestions.title': 'Dawarich から',
+ 'dawarich.suggestions.pending': '{count}件が未処理',
+ 'dawarich.suggestions.loading': 'Dawarich を読み取り中…',
+ 'dawarich.suggestions.notConnected':
+ '設定で Dawarich を接続すると、ここに滞在が表示されます。',
+ 'dawarich.suggestions.unavailable': 'Dawarich を読み取れませんでした。',
+ 'dawarich.suggestions.allHandled': 'ここに記録されたものはすべて処理済みです。',
+ 'dawarich.suggestions.asJournal': '日記を書く',
+ 'dawarich.suggestions.asPlace': '場所として追加',
+ 'dawarich.suggestions.dismiss': '訪れていない場所',
+ 'dawarich.suggestions.dismissed': '却下済み',
+ 'dawarich.suggestions.restore': '戻す',
+ 'dawarich.suggestions.showHandled': '処理済みの{count}件を表示',
+ 'dawarich.suggestions.hideHandled': '処理済みのものを非表示',
+ 'dawarich.suggestions.matchesWish': 'ウィッシュリストにあります:{name}',
+ 'dawarich.suggestions.acceptedAs.place': '場所として追加済み',
+ 'dawarich.suggestions.acceptedAs.journal': '日記に記載済み',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'ウィッシュにチェック済み',
+ 'dawarich.suggestions.sourceChanged':
+ 'この滞在は、使用したあとに Dawarich 側で変更されました。TREK に書いた内容はそのままです。',
+ 'dawarich.suggestions.sourceMissing':
+ 'この滞在は Dawarich にもう存在しません。TREK に書いた内容はそのままです。',
+ 'dawarich.sourceStatus.suggested': '検出済み・未確認',
+ 'dawarich.confidence.high': '確度の高い検出',
+ 'dawarich.confidence.medium': 'ある程度確かな検出',
+ 'dawarich.confidence.low': '不確かな検出',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'この滞在を場所として追加',
+ 'dawarich.accept.title.journal': '日記を書く',
+ 'dawarich.accept.title.bucket_list': 'ウィッシュにチェックを付ける',
+ 'dawarich.accept.confirm.place': '場所を追加',
+ 'dawarich.accept.confirm.journal': 'エントリーを追加',
+ 'dawarich.accept.confirm.bucket_list': 'チェックを付ける',
+ 'dawarich.accept.recorded': '記録:{from}から{to}まで',
+ 'dawarich.accept.duration': '{minutes}分',
+ 'dawarich.accept.name': '名前',
+ 'dawarich.accept.date': '日付',
+ 'dawarich.accept.from': '到着',
+ 'dawarich.accept.to': '出発',
+ 'dawarich.accept.trip': '旅行',
+ 'dawarich.accept.thisTrip': 'この旅行',
+ 'dawarich.accept.pickTrip': '旅行を選択',
+ 'dawarich.accept.day': '日',
+ 'dawarich.accept.noDay': 'まだ日付が未設定',
+ 'dawarich.accept.journal': '日記',
+ 'dawarich.accept.pickJournal': '日記を選択',
+ 'dawarich.accept.notes': 'メモ',
+ 'dawarich.accept.story': 'あなたのストーリー',
+ 'dawarich.accept.storyPlaceholder': 'ここで何がありましたか?',
+ 'dawarich.accept.photosHint': '写真は、エントリーを作成したあとに追加できます。',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Dawarich の記録から追加されました',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'ウィッシュリストを Dawarich と照合',
+ 'dawarich.bucket.description':
+ '行きたかった場所を記録の中から探します。訪問と見なすには、近さと滞在時間の両方が必要です。通り過ぎただけでは含まれません。',
+ 'dawarich.bucket.scan': 'ウィッシュリストを照合',
+ 'dawarich.bucket.scanning': '照合中…',
+ 'dawarich.bucket.noMatches': 'ウィッシュリストの項目は記録の中に見つかりませんでした。',
+ 'dawarich.bucket.alreadyVisited': 'チェック済み',
+ 'dawarich.bucket.confirm': '{count}件にチェックを付ける',
+ 'dawarich.bucket.confirmed': '{count}件のウィッシュにチェックを付けました',
+ 'dawarich.bucket.skipped': '{count}件は座標がないため照合できませんでした。',
+ 'dawarich.bucket.truncated':
+ '最初の項目のみ照合しました。残りはもう一度実行してください。',
+ 'dawarich.bucket.visitedFrom': 'Dawarich の記録からチェックを付けました',
+ 'dawarich.bucket.clearVisit': '元に戻す',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Dawarich からの国',
+ 'dawarich.atlas.description':
+ '記録から滞在したとみられる国です。Atlas に入れたいものを確認してください。自動では追加されず、手動で付けた印もそのまま残ります。',
+ 'dawarich.atlas.load': '国を探す',
+ 'dawarich.atlas.loading': '記録を読み取り中…',
+ 'dawarich.atlas.empty': '記録の中に、TREK にまだない国はありませんでした。',
+ 'dawarich.atlas.cities': '{count}都市',
+ 'dawarich.atlas.citiesOne': '1 都市',
+ 'dawarich.atlas.accept': '{count}か国を追加',
+ 'dawarich.atlas.accepted': '{count}か国を追加しました',
+ 'dawarich.atlas.unresolved': 'TREK はこれらを国と照合できませんでした:{names}。',
+ 'dawarich.atlas.source': 'Dawarich から',
+ 'dawarich.atlas.range': '{from}から{to}までを対象',
+
+ 'dawarich.atlas.trigger': '記録から見つかった願いと国',
+ 'dawarich.atlas.dialogSubtitle': '記録があなたの Atlas について語ること',
+ 'dawarich.atlas.tab.wishes': 'ウィッシュリスト',
+ 'dawarich.atlas.tab.countries': '国',
+ 'dawarich.atlas.window': '直近12か月を確認しました。',
+ 'dawarich.selected': '{count}件を選択中',
+ 'dawarich.again': 'もう一度確認',
+ 'dawarich.bucket.metersAway': '{meters} m 先',
+ 'dawarich.bucket.kilometersAway': '{km} km 先',
+ 'dawarich.bucket.rule': '{meters} m 以内に {minutes} 分以上滞在すると、願いがかなったとみなします。',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/ja/index.ts
+++ b/shared/src/i18n/ja/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts
index 92254920e..675499ed7 100644
--- a/shared/src/i18n/ja/settings.ts
+++ b/shared/src/i18n/ja/settings.ts
@@ -523,6 +523,21 @@ const settings: TranslationStrings = {
'フライトや電車などすべての予約のルートを、個別にオンにしなくても地図上に自動的に表示します。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'このキーが読み取れる範囲',
+ 'settings.apiScopes.hint':
+ 'すべて読み取らせたいキーは、全部オンのままにしてください。オフにした項目は、応答から省かれるのではなく、このキーには拒否されます。',
+ 'settings.apiScopes.all': 'すべて',
+ 'settings.apiScopes.limited': '{total}件中{count}件',
+ 'settings.apiScopes.trips': '旅行',
+ 'settings.apiScopes.days': '日程',
+ 'settings.apiScopes.places': '場所',
+ 'settings.apiScopes.notes': '日ごとのメモ',
+ 'settings.apiScopes.reservations': '予約',
+ 'settings.apiScopes.accommodations': '宿泊',
+ 'settings.apiScopes.travellers': '同行者',
+ 'settings.apiScopes.bucket-list': 'ウィッシュリスト',
+ 'settings.apiScopes.stats': '合計',
'settings.apiKeys.title': 'API キー',
'settings.apiKeys.description': '公開 API 用のキーです。ほかのソフトウェアが旅程を読み取れるようになります。読み取り専用で、変更や削除はできません。',
'settings.apiKeys.create': 'キーを作成',
diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts
index f4b0e12e3..8b4d231b8 100644
--- a/shared/src/i18n/ko/admin.ts
+++ b/shared/src/i18n/ko/admin.ts
@@ -621,6 +621,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': '공유된 네이버 지도 목록에서 장소 가져오기',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'AirTrail 인스턴스에서 항공편 동기화',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ '사용자가 각자 연결한 Dawarich 인스턴스에서 방문 기록과 기록된 경로를 읽어옵니다',
'admin.addons.catalog.llm_parsing.name': 'AI 분석',
'admin.addons.catalog.llm_parsing.description': '기본 분석기가 읽지 못하는 예약을 원하는 AI 모델로 읽어냅니다',
'admin.addons.enabled': '활성화됨',
diff --git a/shared/src/i18n/ko/dawarich.ts b/shared/src/i18n/ko/dawarich.ts
new file mode 100644
index 000000000..5a7de299d
--- /dev/null
+++ b/shared/src/i18n/ko/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ '직접 운영하는 Dawarich 인스턴스를 연결하면 실제로 어디에 다녀왔는지 확인할 수 있습니다. TREK은 기록을 읽어 일기, 장소, 국가를 제안합니다. 확인하기 전까지는 아무것도 추가되지 않으며, Dawarich에 다시 기록되는 내용도 없습니다.',
+ 'dawarich.url': '인스턴스 주소',
+ 'dawarich.apiKey': 'API 키',
+ 'dawarich.apiKeyPlaceholder': 'Dawarich API 키를 붙여넣으세요',
+ 'dawarich.apiKeyHint': 'Dawarich의 계정 → API 키에서 확인할 수 있습니다. 암호화하여 저장되며 다시 표시되지 않습니다.',
+ 'dawarich.allowInsecureTls': '자체 서명 인증서 허용',
+ 'dawarich.allowInsecureTlsHint': '인스턴스가 서버에서 신뢰하지 않는 인증서를 사용할 때만 필요합니다.',
+ 'dawarich.syncEnabled': '새 방문 기록을 자동으로 확인',
+ 'dawarich.syncEnabledHint': '끄면 요청할 때만 TREK이 Dawarich를 읽습니다.',
+ 'dawarich.test.button': '연결 테스트',
+ 'dawarich.test.success': '연결되었습니다. 최근 30일 동안 방문 기록 {count}건을 찾았습니다.',
+ 'dawarich.test.failed': 'Dawarich에 연결할 수 없습니다.',
+ 'dawarich.syncNow': '지금 확인',
+ 'dawarich.connected': '연결됨',
+ 'dawarich.notConnected': '연결되지 않음',
+ 'dawarich.disconnect': '연결 해제',
+ 'dawarich.lastSync': '마지막 확인 {when}',
+ 'dawarich.neverSynced': '아직 확인하지 않았습니다',
+ 'dawarich.syncPartial': '일부 여행은 읽지 못했습니다',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich 연결이 저장되었습니다',
+ 'dawarich.toast.saveError': '연결을 저장할 수 없습니다',
+ 'dawarich.toast.disconnected': 'Dawarich 연결이 해제되었습니다',
+ 'dawarich.toast.synced': '새 방문 기록 {count}건을 찾았습니다',
+ 'dawarich.toast.syncError': 'Dawarich를 읽을 수 없습니다',
+ 'dawarich.toast.syncRunning': '이미 확인 중입니다',
+ 'dawarich.toast.acceptError': '추가할 수 없습니다',
+ 'dawarich.toast.updateError': '이 제안을 업데이트할 수 없습니다',
+ 'dawarich.toast.accepted.place': '여행에 추가했습니다',
+ 'dawarich.toast.accepted.journal': '일기에 추가했습니다',
+ 'dawarich.toast.accepted.bucket_list': '위시리스트에서 완료 처리했습니다',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': '방문 기록',
+ 'dawarich.capability.track': '기록된 경로',
+ 'dawarich.capability.locations': '위시리스트 대조',
+ 'dawarich.capability.visitedCities': '국가 및 도시',
+ 'dawarich.capability.missing': '이 Dawarich 버전에서는 다음 기능을 사용할 수 없습니다: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK이 해당 주소에 연결하지 못했습니다.',
+ 'dawarich.error.unauthorized': 'Dawarich가 API 키를 거부했습니다.',
+ 'dawarich.error.forbidden': '이 API 키에는 해당 내용을 읽을 권한이 없습니다.',
+ 'dawarich.error.not_found': '이 Dawarich 버전에는 해당 엔드포인트가 없습니다.',
+ 'dawarich.error.rate_limited': 'Dawarich가 요청 속도를 낮춰 달라고 응답했습니다. 잠시 후 다시 시도하세요.',
+ 'dawarich.error.server_error': 'Dawarich가 오류로 응답했습니다.',
+ 'dawarich.error.invalid_response': '해당 주소가 Dawarich가 아닌 응답을 보냈습니다.',
+ 'dawarich.error.too_large': 'Dawarich가 TREK이 한 번에 읽을 수 있는 양보다 많은 데이터를 보냈습니다.',
+ 'dawarich.error.not_connected': '아직 연결된 Dawarich 인스턴스가 없습니다.',
+ 'dawarich.error.addon_disabled': '이 인스턴스에서는 Dawarich 애드온이 꺼져 있습니다.',
+ 'dawarich.error.offline': '연결이 필요합니다 — TREK이 지금 오프라인입니다.',
+ 'dawarich.error.invalid_url': 'TREK은 이 주소를 사용할 수 없습니다.',
+ 'dawarich.warning.private_ip': '이 주소는 사설 IP({ip})를 가리킵니다. 의도한 설정인지 확인하세요. 서버에 ALLOW_INTERNAL_NETWORK=true가 필요할 수 있습니다.',
+ 'dawarich.error.unknown': 'Dawarich와 통신하는 중 문제가 발생했습니다.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': '기록된 경로 표시',
+ 'dawarich.trail.hide': '기록된 경로 숨기기',
+ 'dawarich.trail.loading': '기록된 경로를 불러오는 중…',
+ 'dawarich.trail.empty': '해당 날짜에 기록된 내용이 없습니다',
+ 'dawarich.trail.offline': '기록된 경로를 보려면 연결이 필요합니다',
+ 'dawarich.trail.unavailable': '기록된 경로를 불러올 수 없습니다',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes}분',
+ 'dawarich.duration.hours': '{hours}시간',
+ 'dawarich.duration.hoursMinutes': '{hours}시간 {minutes}분',
+ 'dawarich.checkedAgo': '{ago} 확인함',
+
+ 'dawarich.badge.lowConfidence': '불확실',
+ 'dawarich.badge.sourceChanged': 'Dawarich에서 변경됨',
+ 'dawarich.badge.sourceMissing': 'Dawarich에서 사라짐',
+
+ 'dawarich.suggestions.title': 'Dawarich에서',
+ 'dawarich.suggestions.pending': '{count}건이 기다리고 있습니다',
+ 'dawarich.suggestions.loading': 'Dawarich를 읽는 중…',
+ 'dawarich.suggestions.notConnected': '설정에서 Dawarich를 연결하면 방문 기록을 여기서 볼 수 있습니다.',
+ 'dawarich.suggestions.unavailable': 'Dawarich를 읽을 수 없습니다.',
+ 'dawarich.suggestions.allHandled': '여기에 기록된 항목은 모두 처리했습니다.',
+ 'dawarich.suggestions.asJournal': '일기 작성',
+ 'dawarich.suggestions.asPlace': '장소로 추가',
+ 'dawarich.suggestions.dismiss': '방문한 장소가 아님',
+ 'dawarich.suggestions.dismissed': '제외됨',
+ 'dawarich.suggestions.restore': '되돌리기',
+ 'dawarich.suggestions.showHandled': '처리한 {count}건 표시',
+ 'dawarich.suggestions.hideHandled': '처리한 항목 숨기기',
+ 'dawarich.suggestions.matchesWish': '위시리스트에 있음: {name}',
+ 'dawarich.suggestions.acceptedAs.place': '장소로 추가됨',
+ 'dawarich.suggestions.acceptedAs.journal': '일기에 있음',
+ 'dawarich.suggestions.acceptedAs.bucket_list': '위시리스트 완료됨',
+ 'dawarich.suggestions.sourceChanged':
+ '이 방문 기록은 사용한 뒤 Dawarich에서 변경되었습니다. TREK에 작성한 내용은 그대로 유지됩니다.',
+ 'dawarich.suggestions.sourceMissing':
+ '이 방문 기록은 더 이상 Dawarich에 없습니다. TREK에 작성한 내용은 그대로 유지됩니다.',
+ 'dawarich.sourceStatus.suggested': '감지됨, 미확인',
+ 'dawarich.confidence.high': '확실한 감지',
+ 'dawarich.confidence.medium': '비교적 확실한 감지',
+ 'dawarich.confidence.low': '불확실한 감지',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': '이 방문 기록을 장소로 추가',
+ 'dawarich.accept.title.journal': '일기 작성',
+ 'dawarich.accept.title.bucket_list': '위시리스트 완료 처리',
+ 'dawarich.accept.confirm.place': '장소 추가',
+ 'dawarich.accept.confirm.journal': '항목 추가',
+ 'dawarich.accept.confirm.bucket_list': '완료 처리',
+ 'dawarich.accept.recorded': '{from}부터 {to}까지 기록됨',
+ 'dawarich.accept.duration': '{minutes}분',
+ 'dawarich.accept.name': '이름',
+ 'dawarich.accept.date': '날짜',
+ 'dawarich.accept.from': '도착',
+ 'dawarich.accept.to': '출발',
+ 'dawarich.accept.trip': '여행',
+ 'dawarich.accept.thisTrip': '이 여행',
+ 'dawarich.accept.pickTrip': '여행 선택',
+ 'dawarich.accept.day': '일자',
+ 'dawarich.accept.noDay': '아직 일자에 배정되지 않음',
+ 'dawarich.accept.journal': 'Journey',
+ 'dawarich.accept.pickJournal': 'Journey 선택',
+ 'dawarich.accept.notes': '메모',
+ 'dawarich.accept.story': '나의 이야기',
+ 'dawarich.accept.storyPlaceholder': '여기서 어떤 일이 있었나요?',
+ 'dawarich.accept.photosHint': '항목을 만든 뒤에 사진을 추가하세요.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Dawarich 기록에서 추가됨',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': '위시리스트를 Dawarich와 대조하기',
+ 'dawarich.bucket.description':
+ '가고 싶었던 장소를 기록에서 찾아봅니다. 방문으로 인정하려면 충분히 가까이 있었고 머문 시간도 있어야 합니다. 지나가기만 한 것은 포함되지 않습니다.',
+ 'dawarich.bucket.scan': '위시리스트 확인',
+ 'dawarich.bucket.scanning': '확인하는 중…',
+ 'dawarich.bucket.noMatches': '기록에서 위시리스트에 있는 장소를 찾지 못했습니다.',
+ 'dawarich.bucket.alreadyVisited': '이미 완료 처리됨',
+ 'dawarich.bucket.confirm': '{count}건 완료 처리',
+ 'dawarich.bucket.confirmed': '위시리스트 {count}건을 완료 처리했습니다',
+ 'dawarich.bucket.skipped': '{count}건은 좌표가 없어 확인하지 못했습니다.',
+ 'dawarich.bucket.truncated': '앞부분 항목만 확인했습니다. 나머지는 다시 실행하세요.',
+ 'dawarich.bucket.visitedFrom': 'Dawarich 기록을 바탕으로 완료 처리됨',
+ 'dawarich.bucket.clearVisit': '실행 취소',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Dawarich에서 가져온 국가',
+ 'dawarich.atlas.description':
+ '기록에 따르면 다녀온 것으로 보이는 국가입니다. Atlas에 넣고 싶은 국가를 확인해 주세요. 저절로 추가되는 것은 없으며, 직접 표시한 내용도 그대로 유지됩니다.',
+ 'dawarich.atlas.load': '국가 찾기',
+ 'dawarich.atlas.loading': '기록을 읽는 중…',
+ 'dawarich.atlas.empty': '기록에는 TREK에 아직 없는 국가가 없습니다.',
+ 'dawarich.atlas.cities': '도시 {count}곳',
+ 'dawarich.atlas.citiesOne': '도시 1곳',
+ 'dawarich.atlas.accept': '국가 {count}곳 추가',
+ 'dawarich.atlas.accepted': '국가 {count}곳을 추가했습니다',
+ 'dawarich.atlas.unresolved': 'TREK이 다음 항목을 국가와 연결하지 못했습니다: {names}.',
+ 'dawarich.atlas.source': 'Dawarich에서',
+ 'dawarich.atlas.range': '{from}부터 {to}까지 확인함',
+
+ 'dawarich.atlas.trigger': '기록에서 찾은 위시리스트와 국가',
+ 'dawarich.atlas.dialogSubtitle': '기록이 당신의 Atlas에 대해 말해 주는 것',
+ 'dawarich.atlas.tab.wishes': '위시리스트',
+ 'dawarich.atlas.tab.countries': '국가',
+ 'dawarich.atlas.window': '최근 12개월을 확인했습니다.',
+ 'dawarich.selected': '{count}개 선택됨',
+ 'dawarich.again': '다시 확인',
+ 'dawarich.bucket.metersAway': '{meters} m 거리',
+ 'dawarich.bucket.kilometersAway': '{km} km 거리',
+ 'dawarich.bucket.rule': '{meters} m 이내에서 {minutes}분 이상 머무르면 소원을 이룬 것으로 봅니다.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/ko/index.ts
+++ b/shared/src/i18n/ko/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts
index 09b7cefd6..eb09abca2 100644
--- a/shared/src/i18n/ko/settings.ts
+++ b/shared/src/i18n/ko/settings.ts
@@ -540,6 +540,21 @@ const settings: TranslationStrings = {
'항공편, 기차 등 모든 예약의 경로를 개별적으로 켤 필요 없이 지도에 자동으로 표시합니다.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '이 키가 읽을 수 있는 항목',
+ 'settings.apiScopes.hint':
+ '모든 것을 볼 수 있어야 하는 키라면 전부 켜 두세요. 끈 항목은 이 키에 대해 응답에서 빠지는 정도가 아니라 아예 거부됩니다.',
+ 'settings.apiScopes.all': '전체',
+ 'settings.apiScopes.limited': '{total}개 중 {count}개',
+ 'settings.apiScopes.trips': '여행',
+ 'settings.apiScopes.days': '일자',
+ 'settings.apiScopes.places': '장소',
+ 'settings.apiScopes.notes': '일자 메모',
+ 'settings.apiScopes.reservations': '예약',
+ 'settings.apiScopes.accommodations': '숙소',
+ 'settings.apiScopes.travellers': '동행자',
+ 'settings.apiScopes.bucket-list': '위시리스트',
+ 'settings.apiScopes.stats': '합계',
'settings.apiKeys.title': 'API 키',
'settings.apiKeys.description': '공개 API용 키입니다. 다른 소프트웨어가 여행을 읽을 수 있습니다. 읽기 전용이라 무엇도 바꾸거나 지울 수 없습니다.',
'settings.apiKeys.create': '키 만들기',
diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts
index 05339af7b..e0b04c247 100644
--- a/shared/src/i18n/nl/admin.ts
+++ b/shared/src/i18n/nl/admin.ts
@@ -609,6 +609,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importeer plaatsen uit een gedeelde Naver Maps-lijst',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Synchroniseer vluchten vanuit je AirTrail-instantie',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Lees bezoeken en opgenomen routes uit een Dawarich-instantie die elke lezer zelf koppelt',
'admin.addons.catalog.llm_parsing.name': 'AI-analyse',
'admin.addons.catalog.llm_parsing.description': 'Leest boekingen die de ingebouwde parser niet begrijpt, met een AI-model naar keuze',
'admin.addons.enabled': 'Ingeschakeld',
diff --git a/shared/src/i18n/nl/dawarich.ts b/shared/src/i18n/nl/dawarich.ts
new file mode 100644
index 000000000..ae8595e4a
--- /dev/null
+++ b/shared/src/i18n/nl/dawarich.ts
@@ -0,0 +1,180 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Koppel je eigen Dawarich-instantie om te zien waar je echt bent geweest. TREK leest hem uit en stelt dagboekvermeldingen, plaatsen en landen voor — er wordt niets toegevoegd voordat jij het bevestigt, en er wordt niets teruggeschreven naar Dawarich.',
+ 'dawarich.url': 'Adres van de instantie',
+ 'dawarich.apiKey': 'API-sleutel',
+ 'dawarich.apiKeyPlaceholder': 'Plak je Dawarich-API-sleutel',
+ 'dawarich.apiKeyHint':
+ 'Te vinden in Dawarich onder Account → API key. Wordt versleuteld opgeslagen en nooit meer getoond.',
+ 'dawarich.allowInsecureTls': 'Zelfondertekend certificaat toestaan',
+ 'dawarich.allowInsecureTlsHint':
+ 'Alleen nodig als je instantie een certificaat gebruikt dat je server niet vertrouwt.',
+ 'dawarich.syncEnabled': 'Automatisch op nieuwe bezoeken controleren',
+ 'dawarich.syncEnabledHint': 'Uit betekent dat TREK Dawarich alleen uitleest als jij erom vraagt.',
+ 'dawarich.test.button': 'Verbinding testen',
+ 'dawarich.test.success': 'Verbonden. {count} bezoeken gevonden in de laatste 30 dagen.',
+ 'dawarich.test.failed': 'Kon Dawarich niet bereiken.',
+ 'dawarich.syncNow': 'Nu controleren',
+ 'dawarich.connected': 'Verbonden',
+ 'dawarich.notConnected': 'Niet verbonden',
+ 'dawarich.disconnect': 'Verbinding verbreken',
+ 'dawarich.lastSync': 'Laatst gecontroleerd {when}',
+ 'dawarich.neverSynced': 'Nog niet gecontroleerd',
+ 'dawarich.syncPartial': 'sommige reizen konden niet worden gelezen',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich-verbinding opgeslagen',
+ 'dawarich.toast.saveError': 'Kon de verbinding niet opslaan',
+ 'dawarich.toast.disconnected': 'Dawarich losgekoppeld',
+ 'dawarich.toast.synced': '{count} nieuwe bezoeken gevonden',
+ 'dawarich.toast.syncError': 'Kon Dawarich niet uitlezen',
+ 'dawarich.toast.syncRunning': 'Er loopt al een controle',
+ 'dawarich.toast.acceptError': 'Kon dit niet toevoegen',
+ 'dawarich.toast.updateError': 'Kon deze suggestie niet bijwerken',
+ 'dawarich.toast.accepted.place': 'Toegevoegd aan de reis',
+ 'dawarich.toast.accepted.journal': 'Toegevoegd aan het reisverslag',
+ 'dawarich.toast.accepted.bucket_list': 'Afgevinkt op je wensenlijst',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'bezoeken',
+ 'dawarich.capability.track': 'opgenomen route',
+ 'dawarich.capability.locations': 'wensenlijst vergelijken',
+ 'dawarich.capability.visitedCities': 'landen en steden',
+ 'dawarich.capability.missing': 'Deze versie van Dawarich biedt niet: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK kon dat adres niet bereiken.',
+ 'dawarich.error.unauthorized': 'Dawarich heeft de API-sleutel geweigerd.',
+ 'dawarich.error.forbidden': 'Die API-sleutel mag dit niet lezen.',
+ 'dawarich.error.not_found': 'Deze versie van Dawarich heeft dat eindpunt niet.',
+ 'dawarich.error.rate_limited': 'Dawarich vroeg TREK om het rustiger aan te doen. Probeer het zo meteen opnieuw.',
+ 'dawarich.error.server_error': 'Dawarich antwoordde met een fout.',
+ 'dawarich.error.invalid_response': 'Dat adres antwoordde met iets dat geen Dawarich is.',
+ 'dawarich.error.too_large': 'Dawarich stuurde meer gegevens dan TREK in één keer leest.',
+ 'dawarich.error.not_connected': 'Er is nog geen Dawarich-instantie verbonden.',
+ 'dawarich.error.addon_disabled': 'De Dawarich-add-on staat uit voor deze instantie.',
+ 'dawarich.error.offline': 'Hiervoor is een verbinding nodig — TREK is nu offline.',
+ 'dawarich.error.invalid_url': 'TREK kan dit adres niet gebruiken.',
+ 'dawarich.warning.private_ip': 'Dit adres wijst naar een privé-IP ({ip}). Controleer of dat de bedoeling is — de server heeft er mogelijk ALLOW_INTERNAL_NETWORK=true voor nodig.',
+ 'dawarich.error.unknown': 'Er ging iets mis in het contact met Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Opgenomen route tonen',
+ 'dawarich.trail.hide': 'Opgenomen route verbergen',
+ 'dawarich.trail.loading': 'De opgenomen route wordt geladen…',
+ 'dawarich.trail.empty': 'Op deze datums is er niets opgenomen',
+ 'dawarich.trail.offline': 'De opgenomen route heeft een verbinding nodig',
+ 'dawarich.trail.unavailable': 'De opgenomen route kon niet worden geladen',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} u',
+ 'dawarich.duration.hoursMinutes': '{hours} u {minutes} min',
+ 'dawarich.checkedAgo': 'gecontroleerd {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Onzeker',
+ 'dawarich.badge.sourceChanged': 'Gewijzigd in Dawarich',
+ 'dawarich.badge.sourceMissing': 'Weg uit Dawarich',
+
+ 'dawarich.suggestions.title': 'Uit Dawarich',
+ 'dawarich.suggestions.pending': '{count} wachten op je',
+ 'dawarich.suggestions.loading': 'Dawarich wordt uitgelezen…',
+ 'dawarich.suggestions.notConnected': 'Koppel Dawarich in Instellingen om je bezoeken hier te zien.',
+ 'dawarich.suggestions.unavailable': 'Dawarich kon niet worden uitgelezen.',
+ 'dawarich.suggestions.allHandled': 'Alles wat hier is opgenomen, is afgehandeld.',
+ 'dawarich.suggestions.asJournal': 'Een dagboekvermelding schrijven',
+ 'dawarich.suggestions.asPlace': 'Als plaats toevoegen',
+ 'dawarich.suggestions.dismiss': 'Hier ben ik niet geweest',
+ 'dawarich.suggestions.dismissed': 'Weggelegd',
+ 'dawarich.suggestions.restore': 'Terugzetten',
+ 'dawarich.suggestions.showHandled': '{count} al afgehandelde tonen',
+ 'dawarich.suggestions.hideHandled': 'De al afgehandelde verbergen',
+ 'dawarich.suggestions.matchesWish': 'Op je wensenlijst: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Toegevoegd als plaats',
+ 'dawarich.suggestions.acceptedAs.journal': 'In het reisverslag',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Wens afgevinkt',
+ 'dawarich.suggestions.sourceChanged':
+ 'Dit bezoek is in Dawarich gewijzigd sinds je het hebt gebruikt. Wat je in TREK hebt geschreven, blijft onaangeroerd.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Dit bezoek bestaat niet meer in Dawarich. Wat je in TREK hebt geschreven, blijft onaangeroerd.',
+ 'dawarich.sourceStatus.suggested': 'Herkend, niet bevestigd',
+ 'dawarich.confidence.high': 'Zekere herkenning',
+ 'dawarich.confidence.medium': 'Redelijk zekere herkenning',
+ 'dawarich.confidence.low': 'Onzekere herkenning',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Dit bezoek als plaats toevoegen',
+ 'dawarich.accept.title.journal': 'Een dagboekvermelding schrijven',
+ 'dawarich.accept.title.bucket_list': 'Een wens afvinken',
+ 'dawarich.accept.confirm.place': 'Plaats toevoegen',
+ 'dawarich.accept.confirm.journal': 'Vermelding toevoegen',
+ 'dawarich.accept.confirm.bucket_list': 'Afvinken',
+ 'dawarich.accept.recorded': 'Opgenomen van {from} tot {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Naam',
+ 'dawarich.accept.date': 'Datum',
+ 'dawarich.accept.from': 'Aangekomen',
+ 'dawarich.accept.to': 'Vertrokken',
+ 'dawarich.accept.trip': 'Reis',
+ 'dawarich.accept.thisTrip': 'Deze reis',
+ 'dawarich.accept.pickTrip': 'Kies een reis',
+ 'dawarich.accept.day': 'Dag',
+ 'dawarich.accept.noDay': 'Nog niet op een dag',
+ 'dawarich.accept.journal': 'Reisverslag',
+ 'dawarich.accept.pickJournal': 'Kies een reisverslag',
+ 'dawarich.accept.notes': 'Notities',
+ 'dawarich.accept.story': 'Jouw verhaal',
+ 'dawarich.accept.storyPlaceholder': 'Wat is hier gebeurd?',
+ 'dawarich.accept.photosHint': "Voeg foto's toe aan de vermelding nadat die is aangemaakt.",
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Toegevoegd uit je Dawarich-opnames',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Je wensenlijst naast Dawarich leggen',
+ 'dawarich.bucket.description':
+ 'Doorzoekt je opnames op de plaatsen die je wilde bereiken. Voor een bezoek tellen zowel nabijheid als verblijfsduur — voorbijrijden telt niet.',
+ 'dawarich.bucket.scan': 'Wensenlijst controleren',
+ 'dawarich.bucket.scanning': 'Bezig met controleren…',
+ 'dawarich.bucket.noMatches': 'Niets van je wensenlijst kwam in je opnames voor.',
+ 'dawarich.bucket.alreadyVisited': 'Al afgevinkt',
+ 'dawarich.bucket.confirm': '{count} afvinken',
+ 'dawarich.bucket.confirmed': '{count} wensen afgevinkt',
+ 'dawarich.bucket.skipped': '{count} vermeldingen hebben geen coördinaten en konden niet worden gecontroleerd.',
+ 'dawarich.bucket.truncated': 'Alleen de eerste vermeldingen zijn gecontroleerd. Voer het opnieuw uit voor de rest.',
+ 'dawarich.bucket.visitedFrom': 'Afgevinkt op basis van je Dawarich-opnames',
+ 'dawarich.bucket.clearVisit': 'Ongedaan maken',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Landen uit Dawarich',
+ 'dawarich.atlas.description':
+ 'Landen waar je volgens je opnames bent geweest. Bevestig degene die je in je Atlas wilt — er wordt niets uit zichzelf toegevoegd, en wat je met de hand hebt aangevinkt, blijft van jou.',
+ 'dawarich.atlas.load': 'Naar landen zoeken',
+ 'dawarich.atlas.loading': 'Je opnames worden uitgelezen…',
+ 'dawarich.atlas.empty': 'Je opnames tonen geen landen die TREK nog niet heeft.',
+ 'dawarich.atlas.cities': '{count} steden',
+ 'dawarich.atlas.citiesOne': '1 stad',
+ 'dawarich.atlas.accept': '{count} landen toevoegen',
+ 'dawarich.atlas.accepted': '{count} landen toegevoegd',
+ 'dawarich.atlas.unresolved': 'TREK kon deze niet aan een land koppelen: {names}.',
+ 'dawarich.atlas.source': 'Uit Dawarich',
+ 'dawarich.atlas.range': 'Gekeken van {from} tot {to}',
+
+ 'dawarich.atlas.trigger': 'Wensen en landen uit je opnames',
+ 'dawarich.atlas.dialogSubtitle': 'Wat je opnames over je Atlas zeggen',
+ 'dawarich.atlas.tab.wishes': 'Wensenlijst',
+ 'dawarich.atlas.tab.countries': 'Landen',
+ 'dawarich.atlas.window': 'De laatste 12 maanden zijn bekeken.',
+ 'dawarich.selected': '{count} geselecteerd',
+ 'dawarich.again': 'Opnieuw controleren',
+ 'dawarich.bucket.metersAway': '{meters} m verderop',
+ 'dawarich.bucket.kilometersAway': '{km} km verderop',
+ 'dawarich.bucket.rule': 'Een wens telt als bereikt binnen {meters} m en na {minutes} minuten ter plaatse.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/nl/index.ts
+++ b/shared/src/i18n/nl/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts
index 1db115f8e..ab6587085 100644
--- a/shared/src/i18n/nl/settings.ts
+++ b/shared/src/i18n/nl/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Toont automatisch de route van elke vlucht, trein en andere boeking op de kaart, zonder dat u dit per boeking hoeft in te schakelen.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Wat deze sleutel mag lezen',
+ 'settings.apiScopes.hint':
+ 'Laat alles aan staan voor een sleutel die alles mag zien. Alles wat je uitzet, wordt voor deze sleutel geweigerd en niet alleen uit het antwoord weggelaten.',
+ 'settings.apiScopes.all': 'Alles',
+ 'settings.apiScopes.limited': '{count} van {total}',
+ 'settings.apiScopes.trips': 'Reizen',
+ 'settings.apiScopes.days': 'Dagen',
+ 'settings.apiScopes.places': 'Plaatsen',
+ 'settings.apiScopes.notes': 'Dagnotities',
+ 'settings.apiScopes.reservations': 'Boekingen',
+ 'settings.apiScopes.accommodations': 'Accommodatie',
+ 'settings.apiScopes.travellers': 'Wie er meegaan',
+ 'settings.apiScopes.bucket-list': 'Wensenlijst',
+ 'settings.apiScopes.stats': 'Totalen',
'settings.apiKeys.title': 'API-sleutels',
'settings.apiKeys.description': 'Sleutels voor de publieke API, zodat andere software je reizen kan lezen. Alleen-lezen: een sleutel kan niets wijzigen of verwijderen.',
'settings.apiKeys.create': 'Sleutel aanmaken',
diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts
index f44a24937..43eb6f078 100644
--- a/shared/src/i18n/pl/admin.ts
+++ b/shared/src/i18n/pl/admin.ts
@@ -607,6 +607,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importuj miejsca z udostępnionej listy Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Synchronizuj loty ze swojej instancji AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Odczytuj pobyty i zapisane trasy z instancji Dawarich, którą każdy użytkownik podłącza sam',
'admin.addons.catalog.llm_parsing.name': 'Analiza AI',
'admin.addons.catalog.llm_parsing.description': 'Odczytuje rezerwacje, których nie rozumie wbudowany parser, przy użyciu wybranego modelu AI',
'admin.addons.enabled': 'Włączone',
diff --git a/shared/src/i18n/pl/dawarich.ts b/shared/src/i18n/pl/dawarich.ts
new file mode 100644
index 000000000..3b1dfffd0
--- /dev/null
+++ b/shared/src/i18n/pl/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Połącz własną instancję Dawarich, aby zobaczyć, gdzie naprawdę byłeś. TREK ją odczytuje i proponuje wpisy w dzienniku, miejsca i kraje — nic nie zostanie dodane, dopóki tego nie potwierdzisz, i nic nie jest zapisywane z powrotem do Dawarich.',
+ 'dawarich.url': 'Adres instancji',
+ 'dawarich.apiKey': 'Klucz API',
+ 'dawarich.apiKeyPlaceholder': 'Wklej swój klucz API Dawarich',
+ 'dawarich.apiKeyHint': 'Znajdziesz go w Dawarich w Account → API key. Przechowywany w postaci zaszyfrowanej i nigdy więcej niepokazywany.',
+ 'dawarich.allowInsecureTls': 'Zezwalaj na certyfikat z własnym podpisem',
+ 'dawarich.allowInsecureTlsHint': 'Potrzebne tylko wtedy, gdy Twoja instancja używa certyfikatu, któremu Twój serwer nie ufa.',
+ 'dawarich.syncEnabled': 'Sprawdzaj nowe pobyty automatycznie',
+ 'dawarich.syncEnabledHint': 'Gdy wyłączone, TREK odczytuje Dawarich tylko na Twoje polecenie.',
+ 'dawarich.test.button': 'Testuj połączenie',
+ 'dawarich.test.success': 'Połączono. Znaleziono {count} pobytów z ostatnich 30 dni.',
+ 'dawarich.test.failed': 'Nie udało się połączyć z Dawarich.',
+ 'dawarich.syncNow': 'Sprawdź teraz',
+ 'dawarich.connected': 'Połączono',
+ 'dawarich.notConnected': 'Nie połączono',
+ 'dawarich.disconnect': 'Rozłącz',
+ 'dawarich.lastSync': 'Ostatnio sprawdzono {when}',
+ 'dawarich.neverSynced': 'Jeszcze nie sprawdzono',
+ 'dawarich.syncPartial': 'niektórych podróży nie udało się odczytać',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Zapisano połączenie z Dawarich',
+ 'dawarich.toast.saveError': 'Nie udało się zapisać połączenia',
+ 'dawarich.toast.disconnected': 'Rozłączono Dawarich',
+ 'dawarich.toast.synced': 'Znaleziono {count} nowych pobytów',
+ 'dawarich.toast.syncError': 'Nie udało się odczytać Dawarich',
+ 'dawarich.toast.syncRunning': 'Sprawdzanie już trwa',
+ 'dawarich.toast.acceptError': 'Nie udało się tego dodać',
+ 'dawarich.toast.updateError': 'Nie udało się zaktualizować tej sugestii',
+ 'dawarich.toast.accepted.place': 'Dodano do podróży',
+ 'dawarich.toast.accepted.journal': 'Dodano do dziennika',
+ 'dawarich.toast.accepted.bucket_list': 'Odhaczono na liście marzeń',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'pobyty',
+ 'dawarich.capability.track': 'zapisana trasa',
+ 'dawarich.capability.locations': 'dopasowanie do listy marzeń',
+ 'dawarich.capability.visitedCities': 'kraje i miasta',
+ 'dawarich.capability.missing': 'Ta wersja Dawarich nie udostępnia: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK nie mógł połączyć się z tym adresem.',
+ 'dawarich.error.unauthorized': 'Dawarich odrzucił ten klucz API.',
+ 'dawarich.error.forbidden': 'Ten klucz API nie ma prawa tego odczytać.',
+ 'dawarich.error.not_found': 'Ta wersja Dawarich nie ma takiego punktu końcowego.',
+ 'dawarich.error.rate_limited': 'Dawarich poprosił TREK o zwolnienie tempa. Spróbuj ponownie za chwilę.',
+ 'dawarich.error.server_error': 'Dawarich odpowiedział błędem.',
+ 'dawarich.error.invalid_response': 'Ten adres odpowiedział czymś, co nie jest Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich przysłał więcej danych, niż TREK odczyta za jednym razem.',
+ 'dawarich.error.not_connected': 'Nie połączono jeszcze żadnej instancji Dawarich.',
+ 'dawarich.error.addon_disabled': 'Dodatek Dawarich jest wyłączony w tej instancji.',
+ 'dawarich.error.offline': 'To wymaga połączenia — TREK jest teraz offline.',
+ 'dawarich.error.invalid_url': 'TREK nie może użyć tego adresu.',
+ 'dawarich.warning.private_ip': 'Ten adres wskazuje na prywatny IP ({ip}). Sprawdź, czy o to chodziło — serwer może potrzebować ALLOW_INTERNAL_NETWORK=true, aby go osiągnąć.',
+ 'dawarich.error.unknown': 'Coś poszło nie tak w komunikacji z Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Pokaż zapisaną trasę',
+ 'dawarich.trail.hide': 'Ukryj zapisaną trasę',
+ 'dawarich.trail.loading': 'Wczytywanie zapisanej trasy…',
+ 'dawarich.trail.empty': 'W tych dniach nic nie zostało zapisane',
+ 'dawarich.trail.offline': 'Zapisana trasa wymaga połączenia',
+ 'dawarich.trail.unavailable': 'Nie udało się wczytać zapisanej trasy',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} godz.',
+ 'dawarich.duration.hoursMinutes': '{hours} godz. {minutes} min',
+ 'dawarich.checkedAgo': 'sprawdzono {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Niepewne',
+ 'dawarich.badge.sourceChanged': 'Zmienione w Dawarichu',
+ 'dawarich.badge.sourceMissing': 'Zniknęło z Dawaricha',
+
+ 'dawarich.suggestions.title': 'Z Dawarich',
+ 'dawarich.suggestions.pending': '{count} czeka na Ciebie',
+ 'dawarich.suggestions.loading': 'Odczytywanie Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Połącz Dawarich w Ustawieniach, aby zobaczyć tutaj swoje pobyty.',
+ 'dawarich.suggestions.unavailable': 'Nie udało się odczytać Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Wszystko, co tu zapisano, zostało już obsłużone.',
+ 'dawarich.suggestions.asJournal': 'Napisz wpis w dzienniku',
+ 'dawarich.suggestions.asPlace': 'Dodaj jako miejsce',
+ 'dawarich.suggestions.dismiss': 'To nie jest miejsce, w którym byłem',
+ 'dawarich.suggestions.dismissed': 'Odrzucono',
+ 'dawarich.suggestions.restore': 'Przywróć',
+ 'dawarich.suggestions.showHandled': 'Pokaż {count} już obsłużonych',
+ 'dawarich.suggestions.hideHandled': 'Ukryj już obsłużone',
+ 'dawarich.suggestions.matchesWish': 'Na Twojej liście marzeń: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Dodano jako miejsce',
+ 'dawarich.suggestions.acceptedAs.journal': 'W dzienniku',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Marzenie odhaczone',
+ 'dawarich.suggestions.sourceChanged':
+ 'Ten pobyt zmienił się w Dawarich, odkąd go użyłeś. To, co napisałeś w TREK, pozostaje nietknięte.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Ten pobyt już nie istnieje w Dawarich. To, co napisałeś w TREK, pozostaje nietknięte.',
+ 'dawarich.sourceStatus.suggested': 'Wykryte, niepotwierdzone',
+ 'dawarich.confidence.high': 'Pewne wykrycie',
+ 'dawarich.confidence.medium': 'Dość pewne wykrycie',
+ 'dawarich.confidence.low': 'Niepewne wykrycie',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Dodaj ten pobyt jako miejsce',
+ 'dawarich.accept.title.journal': 'Napisz wpis w dzienniku',
+ 'dawarich.accept.title.bucket_list': 'Odhacz marzenie',
+ 'dawarich.accept.confirm.place': 'Dodaj miejsce',
+ 'dawarich.accept.confirm.journal': 'Dodaj wpis',
+ 'dawarich.accept.confirm.bucket_list': 'Odhacz',
+ 'dawarich.accept.recorded': 'Zapisano od {from} do {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Nazwa',
+ 'dawarich.accept.date': 'Data',
+ 'dawarich.accept.from': 'Przyjazd',
+ 'dawarich.accept.to': 'Wyjazd',
+ 'dawarich.accept.trip': 'Podróż',
+ 'dawarich.accept.thisTrip': 'Ta podróż',
+ 'dawarich.accept.pickTrip': 'Wybierz podróż',
+ 'dawarich.accept.day': 'Dzień',
+ 'dawarich.accept.noDay': 'Jeszcze bez przypisanego dnia',
+ 'dawarich.accept.journal': 'Dziennik',
+ 'dawarich.accept.pickJournal': 'Wybierz dziennik',
+ 'dawarich.accept.notes': 'Notatki',
+ 'dawarich.accept.story': 'Twoja historia',
+ 'dawarich.accept.storyPlaceholder': 'Co się tu wydarzyło?',
+ 'dawarich.accept.photosHint': 'Zdjęcia dodasz do wpisu po jego utworzeniu.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Dodane z Twoich zapisów Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Porównaj swoją listę marzeń z Dawarich',
+ 'dawarich.bucket.description':
+ 'Przeszukuje Twoje zapisy pod kątem miejsc, do których chciałeś dotrzeć. Wizyta wymaga zarówno bliskości, jak i spędzonego czasu — przejazd obok się nie liczy.',
+ 'dawarich.bucket.scan': 'Sprawdź listę marzeń',
+ 'dawarich.bucket.scanning': 'Sprawdzanie…',
+ 'dawarich.bucket.noMatches': 'Nic z Twojej listy marzeń nie pojawiło się w Twoich zapisach.',
+ 'dawarich.bucket.alreadyVisited': 'Już odhaczone',
+ 'dawarich.bucket.confirm': 'Odhacz {count}',
+ 'dawarich.bucket.confirmed': 'Odhaczono marzenia: {count}',
+ 'dawarich.bucket.skipped': '{count} pozycji nie ma współrzędnych i nie dało się ich sprawdzić.',
+ 'dawarich.bucket.truncated': 'Sprawdzono tylko pierwsze pozycje. Uruchom ponownie, aby sprawdzić resztę.',
+ 'dawarich.bucket.visitedFrom': 'Odhaczone na podstawie Twoich zapisów z Dawarich',
+ 'dawarich.bucket.clearVisit': 'Cofnij',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Kraje z Dawarich',
+ 'dawarich.atlas.description':
+ 'Kraje, w których według Twoich zapisów byłeś. Potwierdź te, które mają trafić do Twojego Atlasu — nic nie jest dodawane samo, a to, co zaznaczyłeś ręcznie, pozostaje Twoje.',
+ 'dawarich.atlas.load': 'Szukaj krajów',
+ 'dawarich.atlas.loading': 'Odczytywanie Twoich zapisów…',
+ 'dawarich.atlas.empty': 'Twoje zapisy nie pokazują krajów, których TREK jeszcze nie ma.',
+ 'dawarich.atlas.cities': 'miast: {count}',
+ 'dawarich.atlas.citiesOne': '1 miasto',
+ 'dawarich.atlas.accept': 'Dodaj kraje: {count}',
+ 'dawarich.atlas.accepted': 'Dodano kraje: {count}',
+ 'dawarich.atlas.unresolved': 'TREK nie mógł dopasować tych nazw do kraju: {names}.',
+ 'dawarich.atlas.source': 'Z Dawarich',
+ 'dawarich.atlas.range': 'Sprawdzono od {from} do {to}',
+
+ 'dawarich.atlas.trigger': 'Marzenia i kraje z Twoich zapisów',
+ 'dawarich.atlas.dialogSubtitle': 'Co Twoje zapisy mówią o Twoim Atlasie',
+ 'dawarich.atlas.tab.wishes': 'Lista marzeń',
+ 'dawarich.atlas.tab.countries': 'Kraje',
+ 'dawarich.atlas.window': 'Sprawdzono ostatnie 12 miesięcy.',
+ 'dawarich.selected': 'Wybrano: {count}',
+ 'dawarich.again': 'Sprawdź ponownie',
+ 'dawarich.bucket.metersAway': '{meters} m stąd',
+ 'dawarich.bucket.kilometersAway': '{km} km stąd',
+ 'dawarich.bucket.rule': 'Marzenie uznaje się za spełnione w promieniu {meters} m i po {minutes} minutach na miejscu.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/pl/index.ts
+++ b/shared/src/i18n/pl/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts
index 65b6c39f6..91bde5c92 100644
--- a/shared/src/i18n/pl/settings.ts
+++ b/shared/src/i18n/pl/settings.ts
@@ -552,6 +552,21 @@ const settings: TranslationStrings = {
'Automatycznie pokazuje na mapie trasę każdego lotu, pociągu i innej rezerwacji, bez konieczności włączania jej pojedynczo.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Co ten klucz może odczytywać',
+ 'settings.apiScopes.hint':
+ 'Zostaw wszystko włączone dla klucza, który ma widzieć całość. Wszystko, co wyłączysz, jest dla tego klucza odrzucane, a nie tylko pomijane w odpowiedzi.',
+ 'settings.apiScopes.all': 'Wszystko',
+ 'settings.apiScopes.limited': '{count} z {total}',
+ 'settings.apiScopes.trips': 'Podróże',
+ 'settings.apiScopes.days': 'Dni',
+ 'settings.apiScopes.places': 'Miejsca',
+ 'settings.apiScopes.notes': 'Notatki dnia',
+ 'settings.apiScopes.reservations': 'Rezerwacje',
+ 'settings.apiScopes.accommodations': 'Noclegi',
+ 'settings.apiScopes.travellers': 'Kto jedzie',
+ 'settings.apiScopes.bucket-list': 'Lista marzeń',
+ 'settings.apiScopes.stats': 'Podsumowania',
'settings.apiKeys.title': 'Klucze API',
'settings.apiKeys.description': 'Klucze do publicznego API, aby inne oprogramowanie mogło odczytywać Twoje podróże. Tylko odczyt: klucz niczego nie zmieni ani nie usunie.',
'settings.apiKeys.create': 'Utwórz klucz',
diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts
index 61babfc73..d7bff554f 100644
--- a/shared/src/i18n/ru/admin.ts
+++ b/shared/src/i18n/ru/admin.ts
@@ -612,6 +612,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Импорт мест из общего списка Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Синхронизация рейсов с вашего сервера AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Чтение посещений и записанных маршрутов из экземпляра Dawarich, который каждый пользователь подключает сам',
'admin.addons.catalog.llm_parsing.name': 'Разбор с помощью ИИ',
'admin.addons.catalog.llm_parsing.description': 'Читает брони, которые не понимает встроенный анализатор, с помощью выбранной вами модели ИИ',
'admin.addons.enabled': 'Включено',
diff --git a/shared/src/i18n/ru/dawarich.ts b/shared/src/i18n/ru/dawarich.ts
new file mode 100644
index 000000000..7668aed45
--- /dev/null
+++ b/shared/src/i18n/ru/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Подключите свой экземпляр Dawarich, чтобы видеть, где вы действительно побывали. TREK читает его и предлагает записи дневника, места и страны — ничего не добавляется без вашего подтверждения, и в Dawarich ничего не записывается.',
+ 'dawarich.url': 'Адрес экземпляра',
+ 'dawarich.apiKey': 'Ключ API',
+ 'dawarich.apiKeyPlaceholder': 'Вставьте свой ключ API Dawarich',
+ 'dawarich.apiKeyHint': 'Находится в Dawarich в разделе «Аккаунт → Ключ API». Хранится в зашифрованном виде и больше не показывается.',
+ 'dawarich.allowInsecureTls': 'Разрешить самоподписанный сертификат',
+ 'dawarich.allowInsecureTlsHint': 'Нужно только в том случае, если ваш экземпляр использует сертификат, которому ваш сервер не доверяет.',
+ 'dawarich.syncEnabled': 'Автоматически проверять новые остановки',
+ 'dawarich.syncEnabledHint': 'Если выключено, TREK читает Dawarich только по вашему запросу.',
+ 'dawarich.test.button': 'Проверить подключение',
+ 'dawarich.test.success': 'Подключено. За последние 30 дней найдено остановок: {count}.',
+ 'dawarich.test.failed': 'Не удалось связаться с Dawarich.',
+ 'dawarich.syncNow': 'Проверить сейчас',
+ 'dawarich.connected': 'Подключено',
+ 'dawarich.notConnected': 'Не подключено',
+ 'dawarich.disconnect': 'Отключить',
+ 'dawarich.lastSync': 'Последняя проверка: {when}',
+ 'dawarich.neverSynced': 'Ещё не проверялось',
+ 'dawarich.syncPartial': 'некоторые поездки прочитать не удалось',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Подключение к Dawarich сохранено',
+ 'dawarich.toast.saveError': 'Не удалось сохранить подключение',
+ 'dawarich.toast.disconnected': 'Dawarich отключён',
+ 'dawarich.toast.synced': 'Найдено новых остановок: {count}',
+ 'dawarich.toast.syncError': 'Не удалось прочитать Dawarich',
+ 'dawarich.toast.syncRunning': 'Проверка уже выполняется',
+ 'dawarich.toast.acceptError': 'Не удалось это добавить',
+ 'dawarich.toast.updateError': 'Не удалось обновить это предложение',
+ 'dawarich.toast.accepted.place': 'Добавлено в поездку',
+ 'dawarich.toast.accepted.journal': 'Добавлено в дневник',
+ 'dawarich.toast.accepted.bucket_list': 'Отмечено в списке желаний',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'остановки',
+ 'dawarich.capability.track': 'записанный маршрут',
+ 'dawarich.capability.locations': 'сопоставление со списком желаний',
+ 'dawarich.capability.visitedCities': 'страны и города',
+ 'dawarich.capability.missing': 'Эта версия Dawarich не поддерживает: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK не смог связаться с этим адресом.',
+ 'dawarich.error.unauthorized': 'Dawarich отклонил ключ API.',
+ 'dawarich.error.forbidden': 'Этому ключу API не разрешено это читать.',
+ 'dawarich.error.not_found': 'В этой версии Dawarich нет такой конечной точки.',
+ 'dawarich.error.rate_limited': 'Dawarich попросил TREK снизить темп. Повторите попытку чуть позже.',
+ 'dawarich.error.server_error': 'Dawarich ответил ошибкой.',
+ 'dawarich.error.invalid_response': 'По этому адресу ответило что-то, что не является Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich прислал больше данных, чем TREK читает за один раз.',
+ 'dawarich.error.not_connected': 'Ни один экземпляр Dawarich пока не подключён.',
+ 'dawarich.error.addon_disabled': 'Дополнение Dawarich отключено для этого экземпляра.',
+ 'dawarich.error.offline': 'Для этого нужно подключение — TREK сейчас офлайн.',
+ 'dawarich.error.invalid_url': 'TREK не может использовать этот адрес.',
+ 'dawarich.warning.private_ip': 'Этот адрес указывает на частный IP ({ip}). Проверьте, что так и задумано — серверу может понадобиться ALLOW_INTERNAL_NETWORK=true.',
+ 'dawarich.error.unknown': 'При обращении к Dawarich что-то пошло не так.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Показать записанный маршрут',
+ 'dawarich.trail.hide': 'Скрыть записанный маршрут',
+ 'dawarich.trail.loading': 'Загрузка записанного маршрута…',
+ 'dawarich.trail.empty': 'За эти даты ничего не записано',
+ 'dawarich.trail.offline': 'Для записанного маршрута нужно подключение',
+ 'dawarich.trail.unavailable': 'Не удалось загрузить записанный маршрут',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} мин',
+ 'dawarich.duration.hours': '{hours} ч',
+ 'dawarich.duration.hoursMinutes': '{hours} ч {minutes} мин',
+ 'dawarich.checkedAgo': 'проверено {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Неточно',
+ 'dawarich.badge.sourceChanged': 'Изменено в Dawarich',
+ 'dawarich.badge.sourceMissing': 'Исчезло из Dawarich',
+
+ 'dawarich.suggestions.title': 'Из Dawarich',
+ 'dawarich.suggestions.pending': 'Ожидает вас: {count}',
+ 'dawarich.suggestions.loading': 'Чтение Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Подключите Dawarich в настройках, чтобы видеть здесь свои остановки.',
+ 'dawarich.suggestions.unavailable': 'Не удалось прочитать Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Всё, что здесь записано, уже обработано.',
+ 'dawarich.suggestions.asJournal': 'Написать запись в дневник',
+ 'dawarich.suggestions.asPlace': 'Добавить как место',
+ 'dawarich.suggestions.dismiss': 'Я здесь не был',
+ 'dawarich.suggestions.dismissed': 'Отклонено',
+ 'dawarich.suggestions.restore': 'Вернуть',
+ 'dawarich.suggestions.showHandled': 'Показать уже обработанные: {count}',
+ 'dawarich.suggestions.hideHandled': 'Скрыть уже обработанные',
+ 'dawarich.suggestions.matchesWish': 'В вашем списке желаний: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Добавлено как место',
+ 'dawarich.suggestions.acceptedAs.journal': 'В дневнике',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Желание отмечено',
+ 'dawarich.suggestions.sourceChanged':
+ 'Эта остановка изменилась в Dawarich с тех пор, как вы её использовали. То, что вы написали в TREK, осталось нетронутым.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Этой остановки больше нет в Dawarich. То, что вы написали в TREK, осталось нетронутым.',
+ 'dawarich.sourceStatus.suggested': 'Обнаружено, не подтверждено',
+ 'dawarich.confidence.high': 'Уверенное определение',
+ 'dawarich.confidence.medium': 'Довольно уверенное определение',
+ 'dawarich.confidence.low': 'Неуверенное определение',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Добавить эту остановку как место',
+ 'dawarich.accept.title.journal': 'Написать запись в дневник',
+ 'dawarich.accept.title.bucket_list': 'Отметить желание',
+ 'dawarich.accept.confirm.place': 'Добавить место',
+ 'dawarich.accept.confirm.journal': 'Добавить запись',
+ 'dawarich.accept.confirm.bucket_list': 'Отметить',
+ 'dawarich.accept.recorded': 'Записано с {from} по {to}',
+ 'dawarich.accept.duration': '{minutes} мин',
+ 'dawarich.accept.name': 'Название',
+ 'dawarich.accept.date': 'Дата',
+ 'dawarich.accept.from': 'Прибытие',
+ 'dawarich.accept.to': 'Отъезд',
+ 'dawarich.accept.trip': 'Поездка',
+ 'dawarich.accept.thisTrip': 'Эта поездка',
+ 'dawarich.accept.pickTrip': 'Выберите поездку',
+ 'dawarich.accept.day': 'День',
+ 'dawarich.accept.noDay': 'Пока не привязано ко дню',
+ 'dawarich.accept.journal': 'Дневник',
+ 'dawarich.accept.pickJournal': 'Выберите дневник',
+ 'dawarich.accept.notes': 'Заметки',
+ 'dawarich.accept.story': 'Ваша история',
+ 'dawarich.accept.storyPlaceholder': 'Что здесь произошло?',
+ 'dawarich.accept.photosHint': 'Добавьте фотографии к записи после её создания.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Добавлено из ваших записей Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Сверить список желаний с Dawarich',
+ 'dawarich.bucket.description':
+ 'Просматривает ваши записи в поисках мест, куда вы хотели попасть. Для засчитанного посещения нужны и близость, и проведённое время — проезд мимо не считается.',
+ 'dawarich.bucket.scan': 'Проверить список желаний',
+ 'dawarich.bucket.scanning': 'Проверка…',
+ 'dawarich.bucket.noMatches': 'Ничего из вашего списка желаний в записях не нашлось.',
+ 'dawarich.bucket.alreadyVisited': 'Уже отмечено',
+ 'dawarich.bucket.confirm': 'Отметить: {count}',
+ 'dawarich.bucket.confirmed': 'Отмечено желаний: {count}',
+ 'dawarich.bucket.skipped': 'У {count} записей нет координат, их не удалось проверить.',
+ 'dawarich.bucket.truncated': 'Проверены только первые записи. Запустите ещё раз для остальных.',
+ 'dawarich.bucket.visitedFrom': 'Отмечено по вашим записям Dawarich',
+ 'dawarich.bucket.clearVisit': 'Отменить',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Страны из Dawarich',
+ 'dawarich.atlas.description':
+ 'Страны, в которых, судя по вашим записям, вы были. Подтвердите те, что хотите видеть в своём Atlas — ничего не добавляется само собой, а отмеченное вручную остаётся вашим.',
+ 'dawarich.atlas.load': 'Искать страны',
+ 'dawarich.atlas.loading': 'Чтение ваших записей…',
+ 'dawarich.atlas.empty': 'В ваших записях нет стран, которых у TREK ещё нет.',
+ 'dawarich.atlas.cities': 'городов: {count}',
+ 'dawarich.atlas.citiesOne': '1 город',
+ 'dawarich.atlas.accept': 'Добавить стран: {count}',
+ 'dawarich.atlas.accepted': 'Добавлено стран: {count}',
+ 'dawarich.atlas.unresolved': 'TREK не смог сопоставить их со страной: {names}.',
+ 'dawarich.atlas.source': 'Из Dawarich',
+ 'dawarich.atlas.range': 'Просмотрен период с {from} по {to}',
+
+ 'dawarich.atlas.trigger': 'Желания и страны из ваших записей',
+ 'dawarich.atlas.dialogSubtitle': 'Что ваши записи говорят об Атласе',
+ 'dawarich.atlas.tab.wishes': 'Список желаний',
+ 'dawarich.atlas.tab.countries': 'Страны',
+ 'dawarich.atlas.window': 'Просмотрены последние 12 месяцев.',
+ 'dawarich.selected': 'Выбрано: {count}',
+ 'dawarich.again': 'Проверить снова',
+ 'dawarich.bucket.metersAway': 'в {meters} м',
+ 'dawarich.bucket.kilometersAway': 'в {km} км',
+ 'dawarich.bucket.rule': 'Желание считается исполненным в пределах {meters} м и после {minutes} минут на месте.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/ru/index.ts
+++ b/shared/src/i18n/ru/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts
index 8550d8243..c1bbc08d0 100644
--- a/shared/src/i18n/ru/settings.ts
+++ b/shared/src/i18n/ru/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Автоматически показывает на карте маршрут для каждого рейса, поезда и другого бронирования — без необходимости включать это отдельно для каждого элемента.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Что этот ключ может читать',
+ 'settings.apiScopes.hint':
+ 'Оставьте всё включённым для ключа, который должен видеть всё. Всё, что вы выключите, для этого ключа будет запрещено, а не просто опущено в ответе.',
+ 'settings.apiScopes.all': 'Всё',
+ 'settings.apiScopes.limited': '{count} из {total}',
+ 'settings.apiScopes.trips': 'Поездки',
+ 'settings.apiScopes.days': 'Дни',
+ 'settings.apiScopes.places': 'Места',
+ 'settings.apiScopes.notes': 'Заметки дня',
+ 'settings.apiScopes.reservations': 'Бронирования',
+ 'settings.apiScopes.accommodations': 'Проживание',
+ 'settings.apiScopes.travellers': 'Кто едет',
+ 'settings.apiScopes.bucket-list': 'Список желаний',
+ 'settings.apiScopes.stats': 'Итоги',
'settings.apiKeys.title': 'Ключи API',
'settings.apiKeys.description': 'Ключи для публичного API, чтобы другие программы могли читать ваши поездки. Только чтение: ключ ничего не изменит и не удалит.',
'settings.apiKeys.create': 'Создать ключ',
diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts
index 80f0bff35..967a7a260 100644
--- a/shared/src/i18n/sv/admin.ts
+++ b/shared/src/i18n/sv/admin.ts
@@ -670,6 +670,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Importera platser från en delad Naver Maps-lista',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Synkronisera flyg från din AirTrail-instans',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Läs vistelser och inspelade rutter från en Dawarich-instans som varje läsare ansluter själv',
'admin.addons.catalog.llm_parsing.name': 'AI-tolkning',
'admin.addons.catalog.llm_parsing.description': 'Läser bokningar som den inbyggda tolken inte klarar, med en AI-modell du väljer',
'admin.addons.enabled': 'Aktiverad',
diff --git a/shared/src/i18n/sv/dawarich.ts b/shared/src/i18n/sv/dawarich.ts
new file mode 100644
index 000000000..3b223d381
--- /dev/null
+++ b/shared/src/i18n/sv/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Anslut din egen Dawarich-instans för att se var du faktiskt har varit. TREK läser den och föreslår dagboksinlägg, platser och länder – ingenting läggs till förrän du bekräftar det, och ingenting skrivs tillbaka till Dawarich.',
+ 'dawarich.url': 'Instansens adress',
+ 'dawarich.apiKey': 'API-nyckel',
+ 'dawarich.apiKeyPlaceholder': 'Klistra in din Dawarich API-nyckel',
+ 'dawarich.apiKeyHint': 'Finns i Dawarich under Konto → API-nyckel. Lagras krypterad och visas aldrig igen.',
+ 'dawarich.allowInsecureTls': 'Tillåt självsignerat certifikat',
+ 'dawarich.allowInsecureTlsHint': 'Behövs bara om din instans använder ett certifikat som din server inte litar på.',
+ 'dawarich.syncEnabled': 'Leta efter nya vistelser automatiskt',
+ 'dawarich.syncEnabledHint': 'Avstängt betyder att TREK bara läser Dawarich när du ber om det.',
+ 'dawarich.test.button': 'Testa anslutningen',
+ 'dawarich.test.success': 'Ansluten. {count} vistelser hittades de senaste 30 dagarna.',
+ 'dawarich.test.failed': 'Kunde inte nå Dawarich.',
+ 'dawarich.syncNow': 'Leta nu',
+ 'dawarich.connected': 'Ansluten',
+ 'dawarich.notConnected': 'Inte ansluten',
+ 'dawarich.disconnect': 'Koppla från',
+ 'dawarich.lastSync': 'Senast kontrollerad {when}',
+ 'dawarich.neverSynced': 'Inte kontrollerad än',
+ 'dawarich.syncPartial': 'vissa resor kunde inte läsas',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich-anslutningen sparad',
+ 'dawarich.toast.saveError': 'Kunde inte spara anslutningen',
+ 'dawarich.toast.disconnected': 'Dawarich frånkopplad',
+ 'dawarich.toast.synced': '{count} nya vistelser hittades',
+ 'dawarich.toast.syncError': 'Kunde inte läsa Dawarich',
+ 'dawarich.toast.syncRunning': 'En kontroll pågår redan',
+ 'dawarich.toast.acceptError': 'Kunde inte lägga till detta',
+ 'dawarich.toast.updateError': 'Kunde inte uppdatera det här förslaget',
+ 'dawarich.toast.accepted.place': 'Tillagd i resan',
+ 'dawarich.toast.accepted.journal': 'Tillagd i dagboken',
+ 'dawarich.toast.accepted.bucket_list': 'Avbockad från din önskelista',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'vistelser',
+ 'dawarich.capability.track': 'inspelad rutt',
+ 'dawarich.capability.locations': 'matchning mot önskelistan',
+ 'dawarich.capability.visitedCities': 'länder och städer',
+ 'dawarich.capability.missing': 'Den här versionen av Dawarich erbjuder inte: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK kunde inte nå den adressen.',
+ 'dawarich.error.unauthorized': 'Dawarich avvisade API-nyckeln.',
+ 'dawarich.error.forbidden': 'Den API-nyckeln får inte läsa det här.',
+ 'dawarich.error.not_found': 'Den här versionen av Dawarich har inte den ändpunkten.',
+ 'dawarich.error.rate_limited': 'Dawarich bad TREK att sakta ner. Försök igen om en stund.',
+ 'dawarich.error.server_error': 'Dawarich svarade med ett fel.',
+ 'dawarich.error.invalid_response': 'Den adressen svarade med något som inte är Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich skickade mer data än TREK läser på en gång.',
+ 'dawarich.error.not_connected': 'Ingen Dawarich-instans är ansluten än.',
+ 'dawarich.error.addon_disabled': 'Dawarich-tillägget är avstängt för den här instansen.',
+ 'dawarich.error.offline': 'Det här kräver en anslutning — TREK är offline just nu.',
+ 'dawarich.error.invalid_url': 'TREK kan inte använda den adressen.',
+ 'dawarich.warning.private_ip': 'Adressen pekar på en privat IP ({ip}). Kontrollera att det är meningen — servern kan behöva ALLOW_INTERNAL_NETWORK=true för att nå den.',
+ 'dawarich.error.unknown': 'Något gick fel i kommunikationen med Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Visa inspelad rutt',
+ 'dawarich.trail.hide': 'Dölj inspelad rutt',
+ 'dawarich.trail.loading': 'Laddar den inspelade rutten…',
+ 'dawarich.trail.empty': 'Ingenting spelades in de här datumen',
+ 'dawarich.trail.offline': 'Den inspelade rutten kräver en anslutning',
+ 'dawarich.trail.unavailable': 'Den inspelade rutten kunde inte laddas',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} min',
+ 'dawarich.duration.hours': '{hours} tim',
+ 'dawarich.duration.hoursMinutes': '{hours} tim {minutes} min',
+ 'dawarich.checkedAgo': 'kontrollerad {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Osäker',
+ 'dawarich.badge.sourceChanged': 'Ändrad i Dawarich',
+ 'dawarich.badge.sourceMissing': 'Borta från Dawarich',
+
+ 'dawarich.suggestions.title': 'Från Dawarich',
+ 'dawarich.suggestions.pending': '{count} väntar på dig',
+ 'dawarich.suggestions.loading': 'Läser Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Anslut Dawarich under Inställningar för att se dina vistelser här.',
+ 'dawarich.suggestions.unavailable': 'Dawarich kunde inte läsas.',
+ 'dawarich.suggestions.allHandled': 'Allt som spelats in här är hanterat.',
+ 'dawarich.suggestions.asJournal': 'Skriv ett dagboksinlägg',
+ 'dawarich.suggestions.asPlace': 'Lägg till som plats',
+ 'dawarich.suggestions.dismiss': 'Ingen plats jag besökt',
+ 'dawarich.suggestions.dismissed': 'Avfärdad',
+ 'dawarich.suggestions.restore': 'Lägg tillbaka',
+ 'dawarich.suggestions.showHandled': 'Visa {count} redan hanterade',
+ 'dawarich.suggestions.hideHandled': 'Dölj de som redan är hanterade',
+ 'dawarich.suggestions.matchesWish': 'På din önskelista: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Tillagd som plats',
+ 'dawarich.suggestions.acceptedAs.journal': 'I dagboken',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Önskningen avbockad',
+ 'dawarich.suggestions.sourceChanged':
+ 'Den här vistelsen har ändrats i Dawarich sedan du använde den. Det du skrev i TREK är orört.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Den här vistelsen finns inte längre i Dawarich. Det du skrev i TREK är orört.',
+ 'dawarich.sourceStatus.suggested': 'Upptäckt, obekräftad',
+ 'dawarich.confidence.high': 'Säker upptäckt',
+ 'dawarich.confidence.medium': 'Ganska säker upptäckt',
+ 'dawarich.confidence.low': 'Osäker upptäckt',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Lägg till den här vistelsen som en plats',
+ 'dawarich.accept.title.journal': 'Skriv ett dagboksinlägg',
+ 'dawarich.accept.title.bucket_list': 'Bocka av en önskning',
+ 'dawarich.accept.confirm.place': 'Lägg till plats',
+ 'dawarich.accept.confirm.journal': 'Lägg till inlägg',
+ 'dawarich.accept.confirm.bucket_list': 'Bocka av den',
+ 'dawarich.accept.recorded': 'Inspelad {from} till {to}',
+ 'dawarich.accept.duration': '{minutes} min',
+ 'dawarich.accept.name': 'Namn',
+ 'dawarich.accept.date': 'Datum',
+ 'dawarich.accept.from': 'Ankom',
+ 'dawarich.accept.to': 'Lämnade',
+ 'dawarich.accept.trip': 'Resa',
+ 'dawarich.accept.thisTrip': 'Den här resan',
+ 'dawarich.accept.pickTrip': 'Välj en resa',
+ 'dawarich.accept.day': 'Dag',
+ 'dawarich.accept.noDay': 'Inte på någon dag än',
+ 'dawarich.accept.journal': 'Dagbok',
+ 'dawarich.accept.pickJournal': 'Välj en dagbok',
+ 'dawarich.accept.notes': 'Anteckningar',
+ 'dawarich.accept.story': 'Din berättelse',
+ 'dawarich.accept.storyPlaceholder': 'Vad hände här?',
+ 'dawarich.accept.photosHint': 'Lägg till foton i inlägget när det har skapats.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Tillagd från dina Dawarich-inspelningar',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Stäm av din önskelista mot Dawarich',
+ 'dawarich.bucket.description':
+ 'Söker igenom dina inspelningar efter de platser du ville nå. Ett besök kräver både närhet och tid på plats – att köra förbi räknas inte.',
+ 'dawarich.bucket.scan': 'Stäm av önskelistan',
+ 'dawarich.bucket.scanning': 'Stämmer av…',
+ 'dawarich.bucket.noMatches': 'Inget på din önskelista dök upp i dina inspelningar.',
+ 'dawarich.bucket.alreadyVisited': 'Redan avbockad',
+ 'dawarich.bucket.confirm': 'Bocka av {count}',
+ 'dawarich.bucket.confirmed': '{count} önskningar avbockade',
+ 'dawarich.bucket.skipped': '{count} poster saknar koordinater och kunde inte kontrolleras.',
+ 'dawarich.bucket.truncated': 'Bara de första posterna kontrollerades. Kör igen för resten.',
+ 'dawarich.bucket.visitedFrom': 'Avbockad utifrån dina Dawarich-inspelningar',
+ 'dawarich.bucket.clearVisit': 'Ångra',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Länder från Dawarich',
+ 'dawarich.atlas.description':
+ 'Länder som dina inspelningar säger att du har varit i. Bekräfta dem du vill ha i din Atlas – ingenting läggs till av sig självt, och det du markerat för hand förblir ditt.',
+ 'dawarich.atlas.load': 'Leta efter länder',
+ 'dawarich.atlas.loading': 'Läser dina inspelningar…',
+ 'dawarich.atlas.empty': 'Dina inspelningar visar inga länder som TREK inte redan har.',
+ 'dawarich.atlas.cities': '{count} städer',
+ 'dawarich.atlas.citiesOne': '1 stad',
+ 'dawarich.atlas.accept': 'Lägg till {count} länder',
+ 'dawarich.atlas.accepted': '{count} länder tillagda',
+ 'dawarich.atlas.unresolved': 'TREK kunde inte matcha dessa mot ett land: {names}.',
+ 'dawarich.atlas.source': 'Från Dawarich',
+ 'dawarich.atlas.range': 'Tittade på {from} till {to}',
+
+ 'dawarich.atlas.trigger': 'Önskningar och länder från dina inspelningar',
+ 'dawarich.atlas.dialogSubtitle': 'Vad dina inspelningar säger om din Atlas',
+ 'dawarich.atlas.tab.wishes': 'Önskelista',
+ 'dawarich.atlas.tab.countries': 'Länder',
+ 'dawarich.atlas.window': 'De senaste 12 månaderna har genomsökts.',
+ 'dawarich.selected': '{count} valda',
+ 'dawarich.again': 'Kontrollera igen',
+ 'dawarich.bucket.metersAway': '{meters} m bort',
+ 'dawarich.bucket.kilometersAway': '{km} km bort',
+ 'dawarich.bucket.rule': 'En önskning räknas som uppfylld inom {meters} m och efter {minutes} minuter på plats.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/sv/index.ts
+++ b/shared/src/i18n/sv/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts
index 5b3fac127..0fe9c5b1c 100644
--- a/shared/src/i18n/sv/settings.ts
+++ b/shared/src/i18n/sv/settings.ts
@@ -549,6 +549,21 @@ const settings: TranslationStrings = {
'Visar automatiskt rutten för varje flyg, tåg och annan bokning på kartan, utan att du behöver aktivera det för varje bokning för sig.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Vad den här nyckeln får läsa',
+ 'settings.apiScopes.hint':
+ 'Lämna allt påslaget för en nyckel som ska se allt. Det du stänger av nekas för den här nyckeln, det utelämnas inte bara ur svaret.',
+ 'settings.apiScopes.all': 'Allt',
+ 'settings.apiScopes.limited': '{count} av {total}',
+ 'settings.apiScopes.trips': 'Resor',
+ 'settings.apiScopes.days': 'Dagar',
+ 'settings.apiScopes.places': 'Platser',
+ 'settings.apiScopes.notes': 'Dagsanteckningar',
+ 'settings.apiScopes.reservations': 'Bokningar',
+ 'settings.apiScopes.accommodations': 'Boende',
+ 'settings.apiScopes.travellers': 'Vilka som följer med',
+ 'settings.apiScopes.bucket-list': 'Önskelista',
+ 'settings.apiScopes.stats': 'Totalsummor',
'settings.apiKeys.title': 'API-nycklar',
'settings.apiKeys.description': 'Nycklar för det publika API:et, så att annan programvara kan läsa dina resor. Endast läsning: en nyckel kan inte ändra eller ta bort något.',
'settings.apiKeys.create': 'Skapa nyckel',
diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts
index 9a46d58e7..4d41daf19 100644
--- a/shared/src/i18n/tr/admin.ts
+++ b/shared/src/i18n/tr/admin.ts
@@ -655,6 +655,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Paylaşılan bir Naver Haritalar listesinden yerleri içe aktarın',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'AirTrail örneğinizden uçuşları eşitleyin',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Her kullanıcının kendi bağladığı bir Dawarich sunucusundan ziyaretleri ve kaydedilen rotaları okuyun',
'admin.addons.catalog.llm_parsing.name': 'Yapay zekâ ile ayrıştırma',
'admin.addons.catalog.llm_parsing.description': 'Yerleşik ayrıştırıcının okuyamadığı rezervasyonları, seçtiğiniz yapay zekâ modeliyle okur',
'admin.addons.enabled': 'Etkinleştirilmiş',
diff --git a/shared/src/i18n/tr/dawarich.ts b/shared/src/i18n/tr/dawarich.ts
new file mode 100644
index 000000000..90572524a
--- /dev/null
+++ b/shared/src/i18n/tr/dawarich.ts
@@ -0,0 +1,181 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Gerçekte nerelere gittiğinizi görmek için kendi Dawarich sunucunuzu bağlayın. TREK bu kayıtları okur ve günlük kayıtları, yerler ve ülkeler önerir — siz onaylamadan hiçbir şey eklenmez ve Dawarich’e hiçbir şey geri yazılmaz.',
+ 'dawarich.url': 'Sunucu adresi',
+ 'dawarich.apiKey': 'API anahtarı',
+ 'dawarich.apiKeyPlaceholder': 'Dawarich API anahtarınızı yapıştırın',
+ 'dawarich.apiKeyHint':
+ 'Dawarich’te Hesap → API anahtarı altında bulunur. Şifreli olarak saklanır ve bir daha gösterilmez.',
+ 'dawarich.allowInsecureTls': 'Kendinden imzalı sertifikaya izin ver',
+ 'dawarich.allowInsecureTlsHint':
+ 'Yalnızca sunucunuzun güvenmediği bir sertifika kullanan bir örnek için gerekir.',
+ 'dawarich.syncEnabled': 'Yeni konaklamaları otomatik olarak denetle',
+ 'dawarich.syncEnabledHint': 'Kapalıyken TREK, Dawarich’i yalnızca siz istediğinizde okur.',
+ 'dawarich.test.button': 'Bağlantıyı test et',
+ 'dawarich.test.success': 'Bağlandı. Son 30 günde {count} konaklama bulundu.',
+ 'dawarich.test.failed': 'Dawarich’e ulaşılamadı.',
+ 'dawarich.syncNow': 'Şimdi denetle',
+ 'dawarich.connected': 'Bağlandı',
+ 'dawarich.notConnected': 'Bağlı değil',
+ 'dawarich.disconnect': 'Bağlantıyı kes',
+ 'dawarich.lastSync': 'Son denetim {when}',
+ 'dawarich.neverSynced': 'Henüz denetlenmedi',
+ 'dawarich.syncPartial': 'bazı geziler okunamadı',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich bağlantısı kaydedildi',
+ 'dawarich.toast.saveError': 'Bağlantı kaydedilemedi',
+ 'dawarich.toast.disconnected': 'Dawarich bağlantısı kesildi',
+ 'dawarich.toast.synced': '{count} yeni konaklama bulundu',
+ 'dawarich.toast.syncError': 'Dawarich okunamadı',
+ 'dawarich.toast.syncRunning': 'Zaten bir kontrol çalışıyor',
+ 'dawarich.toast.acceptError': 'Bu eklenemedi',
+ 'dawarich.toast.updateError': 'Bu öneri güncellenemedi',
+ 'dawarich.toast.accepted.place': 'Geziye eklendi',
+ 'dawarich.toast.accepted.journal': 'Günlüğe eklendi',
+ 'dawarich.toast.accepted.bucket_list': 'Dilek listenizden işaretlendi',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'konaklamalar',
+ 'dawarich.capability.track': 'kaydedilen rota',
+ 'dawarich.capability.locations': 'dilek listesi eşleştirme',
+ 'dawarich.capability.visitedCities': 'ülkeler ve şehirler',
+ 'dawarich.capability.missing': 'Bu Dawarich sürümü şunları sunmuyor: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK bu adrese ulaşamadı.',
+ 'dawarich.error.unauthorized': 'Dawarich API anahtarını reddetti.',
+ 'dawarich.error.forbidden': 'Bu API anahtarının bunu okuma izni yok.',
+ 'dawarich.error.not_found': 'Bu Dawarich sürümünde böyle bir uç nokta yok.',
+ 'dawarich.error.rate_limited': 'Dawarich, TREK’ten yavaşlamasını istedi. Birazdan yeniden deneyin.',
+ 'dawarich.error.server_error': 'Dawarich bir hatayla yanıt verdi.',
+ 'dawarich.error.invalid_response': 'Bu adres, Dawarich olmayan bir şeyle yanıt verdi.',
+ 'dawarich.error.too_large': 'Dawarich, TREK’in bir kerede okuyacağından daha fazla veri gönderdi.',
+ 'dawarich.error.not_connected': 'Henüz bağlı bir Dawarich sunucusu yok.',
+ 'dawarich.error.addon_disabled': 'Dawarich eklentisi bu örnek için kapalı.',
+ 'dawarich.error.offline': 'Bunun için bağlantı gerekiyor — TREK şu anda çevrimdışı.',
+ 'dawarich.error.invalid_url': 'TREK bu adresi kullanamıyor.',
+ 'dawarich.warning.private_ip': 'Bu adres özel bir IP’ye çıkıyor ({ip}). Böyle olmasını istediğinden emin ol — sunucunun buna erişmesi için ALLOW_INTERNAL_NETWORK=true gerekebilir.',
+ 'dawarich.error.unknown': 'Dawarich ile iletişimde bir şeyler ters gitti.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Kaydedilen rotayı göster',
+ 'dawarich.trail.hide': 'Kaydedilen rotayı gizle',
+ 'dawarich.trail.loading': 'Kaydedilen rota yükleniyor…',
+ 'dawarich.trail.empty': 'Bu tarihlerde hiçbir şey kaydedilmemiş',
+ 'dawarich.trail.offline': 'Kaydedilen rota için bağlantı gerekir',
+ 'dawarich.trail.unavailable': 'Kaydedilen rota yüklenemedi',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} dk',
+ 'dawarich.duration.hours': '{hours} sa',
+ 'dawarich.duration.hoursMinutes': '{hours} sa {minutes} dk',
+ 'dawarich.checkedAgo': '{ago} kontrol edildi',
+
+ 'dawarich.badge.lowConfidence': 'Belirsiz',
+ 'dawarich.badge.sourceChanged': 'Dawarich’te değişti',
+ 'dawarich.badge.sourceMissing': 'Dawarich’ten kayboldu',
+
+ 'dawarich.suggestions.title': 'Dawarich’ten',
+ 'dawarich.suggestions.pending': '{count} tanesi sizi bekliyor',
+ 'dawarich.suggestions.loading': 'Dawarich okunuyor…',
+ 'dawarich.suggestions.notConnected':
+ 'Konaklamalarınızı burada görmek için Ayarlar’dan Dawarich’i bağlayın.',
+ 'dawarich.suggestions.unavailable': 'Dawarich okunamadı.',
+ 'dawarich.suggestions.allHandled': 'Burada kaydedilen her şey ele alındı.',
+ 'dawarich.suggestions.asJournal': 'Günlük kaydı yaz',
+ 'dawarich.suggestions.asPlace': 'Yer olarak ekle',
+ 'dawarich.suggestions.dismiss': 'Ziyaret ettiğim bir yer değil',
+ 'dawarich.suggestions.dismissed': 'Yok sayıldı',
+ 'dawarich.suggestions.restore': 'Geri koy',
+ 'dawarich.suggestions.showHandled': 'Ele alınmış {count} tanesini göster',
+ 'dawarich.suggestions.hideHandled': 'Ele alınmış olanları gizle',
+ 'dawarich.suggestions.matchesWish': 'Dilek listenizde: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Yer olarak eklendi',
+ 'dawarich.suggestions.acceptedAs.journal': 'Günlükte',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Dilek işaretlendi',
+ 'dawarich.suggestions.sourceChanged':
+ 'Bu konaklama, siz kullandıktan sonra Dawarich’te değişti. TREK’te yazdıklarınıza dokunulmadı.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Bu konaklama artık Dawarich’te yok. TREK’te yazdıklarınıza dokunulmadı.',
+ 'dawarich.sourceStatus.suggested': 'Algılandı, onaylanmadı',
+ 'dawarich.confidence.high': 'Kesine yakın algılama',
+ 'dawarich.confidence.medium': 'Oldukça güvenilir algılama',
+ 'dawarich.confidence.low': 'Belirsiz algılama',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Bu konaklamayı yer olarak ekle',
+ 'dawarich.accept.title.journal': 'Günlük kaydı yaz',
+ 'dawarich.accept.title.bucket_list': 'Bir dileği işaretle',
+ 'dawarich.accept.confirm.place': 'Yer ekle',
+ 'dawarich.accept.confirm.journal': 'Kayıt ekle',
+ 'dawarich.accept.confirm.bucket_list': 'İşaretle',
+ 'dawarich.accept.recorded': '{from} ile {to} arasında kaydedildi',
+ 'dawarich.accept.duration': '{minutes} dk',
+ 'dawarich.accept.name': 'Ad',
+ 'dawarich.accept.date': 'Tarih',
+ 'dawarich.accept.from': 'Varış',
+ 'dawarich.accept.to': 'Ayrılış',
+ 'dawarich.accept.trip': 'Gezi',
+ 'dawarich.accept.thisTrip': 'Bu gezi',
+ 'dawarich.accept.pickTrip': 'Bir gezi seçin',
+ 'dawarich.accept.day': 'Gün',
+ 'dawarich.accept.noDay': 'Henüz bir güne bağlı değil',
+ 'dawarich.accept.journal': 'Günlük',
+ 'dawarich.accept.pickJournal': 'Bir günlük seçin',
+ 'dawarich.accept.notes': 'Notlar',
+ 'dawarich.accept.story': 'Hikâyeniz',
+ 'dawarich.accept.storyPlaceholder': 'Burada neler oldu?',
+ 'dawarich.accept.photosHint': 'Kayıt oluşturulduktan sonra fotoğraf ekleyebilirsiniz.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Dawarich kayıtlarından eklendi',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Dilek listenizi Dawarich ile karşılaştırın',
+ 'dawarich.bucket.description':
+ 'Ulaşmak istediğiniz yerler için kayıtlarınıza bakar. Bir ziyaret hem yakınlık hem de orada geçirilen süre ister — yanından geçmek sayılmaz.',
+ 'dawarich.bucket.scan': 'Dilek listesini denetle',
+ 'dawarich.bucket.scanning': 'Denetleniyor…',
+ 'dawarich.bucket.noMatches': 'Dilek listenizden hiçbir şey kayıtlarınızda çıkmadı.',
+ 'dawarich.bucket.alreadyVisited': 'Zaten işaretlenmiş',
+ 'dawarich.bucket.confirm': '{count} tanesini işaretle',
+ 'dawarich.bucket.confirmed': '{count} dilek işaretlendi',
+ 'dawarich.bucket.skipped': '{count} kaydın koordinatı yok, denetlenemedi.',
+ 'dawarich.bucket.truncated': 'Yalnızca ilk kayıtlar denetlendi. Kalanı için yeniden çalıştırın.',
+ 'dawarich.bucket.visitedFrom': 'Dawarich kayıtlarınızdan işaretlendi',
+ 'dawarich.bucket.clearVisit': 'Geri al',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Dawarich’ten gelen ülkeler',
+ 'dawarich.atlas.description':
+ 'Kayıtlarınıza göre bulunduğunuz ülkeler. Atlas’ınızda görmek istediklerinizi onaylayın — hiçbir şey kendiliğinden eklenmez ve elle işaretlediğiniz her şey size ait kalır.',
+ 'dawarich.atlas.load': 'Ülkeleri ara',
+ 'dawarich.atlas.loading': 'Kayıtlarınız okunuyor…',
+ 'dawarich.atlas.empty': 'Kayıtlarınız, TREK’te halihazırda olmayan bir ülke göstermiyor.',
+ 'dawarich.atlas.cities': '{count} şehir',
+ 'dawarich.atlas.citiesOne': '1 şehir',
+ 'dawarich.atlas.accept': '{count} ülke ekle',
+ 'dawarich.atlas.accepted': '{count} ülke eklendi',
+ 'dawarich.atlas.unresolved': 'TREK bunları bir ülkeyle eşleştiremedi: {names}.',
+ 'dawarich.atlas.source': 'Dawarich’ten',
+ 'dawarich.atlas.range': '{from} ile {to} arası incelendi',
+
+ 'dawarich.atlas.trigger': 'Kayıtlarından gelen dilekler ve ülkeler',
+ 'dawarich.atlas.dialogSubtitle': 'Kayıtların Atlas’ın hakkında ne söylüyor',
+ 'dawarich.atlas.tab.wishes': 'Dilek listesi',
+ 'dawarich.atlas.tab.countries': 'Ülkeler',
+ 'dawarich.atlas.window': 'Son 12 ay incelendi.',
+ 'dawarich.selected': '{count} seçildi',
+ 'dawarich.again': 'Yeniden kontrol et',
+ 'dawarich.bucket.metersAway': '{meters} m uzakta',
+ 'dawarich.bucket.kilometersAway': '{km} km uzakta',
+ 'dawarich.bucket.rule': 'Bir dilek {meters} m yakınlıkta ve yerinde {minutes} dakika sonra ulaşılmış sayılır.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/tr/index.ts
+++ b/shared/src/i18n/tr/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts
index d88a99bfc..38d769e3d 100644
--- a/shared/src/i18n/tr/settings.ts
+++ b/shared/src/i18n/tr/settings.ts
@@ -550,6 +550,21 @@ const settings: TranslationStrings = {
'Haritada her uçuş, tren ve diğer rezervasyonun rotasını, tek tek açmaya gerek kalmadan otomatik olarak gösterir.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Bu anahtar neleri okuyabilir',
+ 'settings.apiScopes.hint':
+ 'Her şeyi görmesi gereken bir anahtar için hepsini açık bırakın. Kapattığınız her şey bu anahtar için reddedilir, yalnızca yanıttan çıkarılmakla kalmaz.',
+ 'settings.apiScopes.all': 'Her şey',
+ 'settings.apiScopes.limited': '{total} içinden {count}',
+ 'settings.apiScopes.trips': 'Geziler',
+ 'settings.apiScopes.days': 'Günler',
+ 'settings.apiScopes.places': 'Yerler',
+ 'settings.apiScopes.notes': 'Gün notları',
+ 'settings.apiScopes.reservations': 'Rezervasyonlar',
+ 'settings.apiScopes.accommodations': 'Konaklama',
+ 'settings.apiScopes.travellers': 'Kimler geliyor',
+ 'settings.apiScopes.bucket-list': 'Dilek listesi',
+ 'settings.apiScopes.stats': 'Toplamlar',
'settings.apiKeys.title': 'API Anahtarları',
'settings.apiKeys.description': 'Genel API için anahtarlar, böylece başka yazılımlar gezilerini okuyabilir. Yalnızca okuma: bir anahtar hiçbir şeyi değiştiremez veya silemez.',
'settings.apiKeys.create': 'Anahtar oluştur',
diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts
index 2e9466081..43213fbbd 100644
--- a/shared/src/i18n/uk/admin.ts
+++ b/shared/src/i18n/uk/admin.ts
@@ -609,6 +609,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Імпорт місць зі спільного списку Naver Maps',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Синхронізація рейсів із вашого сервера AirTrail',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Читає перебування та записані маршрути із сервера Dawarich, який кожен користувач підключає сам',
'admin.addons.catalog.llm_parsing.name': 'Розбір за допомогою ШІ',
'admin.addons.catalog.llm_parsing.description': 'Читає бронювання, які не розпізнає вбудований аналізатор, за допомогою обраної вами моделі ШІ',
'admin.addons.enabled': 'Увімкнено',
diff --git a/shared/src/i18n/uk/dawarich.ts b/shared/src/i18n/uk/dawarich.ts
new file mode 100644
index 000000000..e782b68af
--- /dev/null
+++ b/shared/src/i18n/uk/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Підключіть власний сервер Dawarich, щоб бачити, де ви насправді були. TREK читає його та пропонує записи щоденника, місця й країни — нічого не додається, доки ви це не підтвердите, і нічого не записується назад у Dawarich.',
+ 'dawarich.url': 'Адреса сервера',
+ 'dawarich.apiKey': 'Ключ API',
+ 'dawarich.apiKeyPlaceholder': 'Вставте свій ключ API Dawarich',
+ 'dawarich.apiKeyHint': 'Шукайте в Dawarich у розділі «Обліковий запис → Ключ API». Зберігається зашифрованим і більше ніколи не показується.',
+ 'dawarich.allowInsecureTls': 'Дозволити самопідписаний сертифікат',
+ 'dawarich.allowInsecureTlsHint': 'Потрібно лише тоді, коли ваш сервер використовує сертифікат, якому ваш сервер не довіряє.',
+ 'dawarich.syncEnabled': 'Автоматично перевіряти нові перебування',
+ 'dawarich.syncEnabledHint': 'Якщо вимкнено, TREK читає Dawarich лише на ваш запит.',
+ 'dawarich.test.button': 'Перевірити з’єднання',
+ 'dawarich.test.success': 'З’єднано. Знайдено перебувань за останні 30 днів: {count}.',
+ 'dawarich.test.failed': 'Не вдалося зв’язатися з Dawarich.',
+ 'dawarich.syncNow': 'Перевірити зараз',
+ 'dawarich.connected': 'Підключено',
+ 'dawarich.notConnected': 'Не підключено',
+ 'dawarich.disconnect': 'Відключити',
+ 'dawarich.lastSync': 'Остання перевірка: {when}',
+ 'dawarich.neverSynced': 'Ще не перевірялося',
+ 'dawarich.syncPartial': 'деякі подорожі не вдалося прочитати',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'З’єднання з Dawarich збережено',
+ 'dawarich.toast.saveError': 'Не вдалося зберегти з’єднання',
+ 'dawarich.toast.disconnected': 'Dawarich відключено',
+ 'dawarich.toast.synced': 'Знайдено нових перебувань: {count}',
+ 'dawarich.toast.syncError': 'Не вдалося прочитати Dawarich',
+ 'dawarich.toast.syncRunning': 'Перевірка вже виконується',
+ 'dawarich.toast.acceptError': 'Не вдалося це додати',
+ 'dawarich.toast.updateError': 'Не вдалося оновити цю пропозицію',
+ 'dawarich.toast.accepted.place': 'Додано до подорожі',
+ 'dawarich.toast.accepted.journal': 'Додано до щоденника',
+ 'dawarich.toast.accepted.bucket_list': 'Позначено у списку бажань',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'перебування',
+ 'dawarich.capability.track': 'записаний маршрут',
+ 'dawarich.capability.locations': 'звіряння зі списком бажань',
+ 'dawarich.capability.visitedCities': 'країни та міста',
+ 'dawarich.capability.missing': 'Ця версія Dawarich не надає: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK не зміг зв’язатися з цією адресою.',
+ 'dawarich.error.unauthorized': 'Dawarich відхилив ключ API.',
+ 'dawarich.error.forbidden': 'Цьому ключу API не дозволено це читати.',
+ 'dawarich.error.not_found': 'Ця версія Dawarich не має такої кінцевої точки.',
+ 'dawarich.error.rate_limited': 'Dawarich попросив TREK сповільнитися. Спробуйте трохи згодом.',
+ 'dawarich.error.server_error': 'Dawarich відповів помилкою.',
+ 'dawarich.error.invalid_response': 'Ця адреса відповіла чимось, що не є Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich надіслав більше даних, ніж TREK читає за один раз.',
+ 'dawarich.error.not_connected': 'Жодного сервера Dawarich ще не підключено.',
+ 'dawarich.error.addon_disabled': 'Доповнення Dawarich вимкнено на цьому сервері.',
+ 'dawarich.error.offline': 'Для цього потрібне з’єднання — TREK зараз офлайн.',
+ 'dawarich.error.invalid_url': 'TREK не може використати цю адресу.',
+ 'dawarich.warning.private_ip': 'Ця адреса вказує на приватну IP ({ip}). Перевірте, чи саме це мали на увазі — серверу може знадобитися ALLOW_INTERNAL_NETWORK=true.',
+ 'dawarich.error.unknown': 'Щось пішло не так під час обміну даними з Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Показати записаний маршрут',
+ 'dawarich.trail.hide': 'Сховати записаний маршрут',
+ 'dawarich.trail.loading': 'Завантаження записаного маршруту…',
+ 'dawarich.trail.empty': 'За ці дати нічого не записано',
+ 'dawarich.trail.offline': 'Для записаного маршруту потрібне з’єднання',
+ 'dawarich.trail.unavailable': 'Не вдалося завантажити записаний маршрут',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} хв',
+ 'dawarich.duration.hours': '{hours} год',
+ 'dawarich.duration.hoursMinutes': '{hours} год {minutes} хв',
+ 'dawarich.checkedAgo': 'перевірено {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Неточно',
+ 'dawarich.badge.sourceChanged': 'Змінено в Dawarich',
+ 'dawarich.badge.sourceMissing': 'Зникло з Dawarich',
+
+ 'dawarich.suggestions.title': 'З Dawarich',
+ 'dawarich.suggestions.pending': 'Очікує на вас: {count}',
+ 'dawarich.suggestions.loading': 'Читання Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Підключіть Dawarich у налаштуваннях, щоб бачити тут свої перебування.',
+ 'dawarich.suggestions.unavailable': 'Не вдалося прочитати Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Усе, що тут записано, уже опрацьовано.',
+ 'dawarich.suggestions.asJournal': 'Написати запис у щоденник',
+ 'dawarich.suggestions.asPlace': 'Додати як місце',
+ 'dawarich.suggestions.dismiss': 'Я тут не був',
+ 'dawarich.suggestions.dismissed': 'Відхилено',
+ 'dawarich.suggestions.restore': 'Повернути',
+ 'dawarich.suggestions.showHandled': 'Показати вже опрацьовані: {count}',
+ 'dawarich.suggestions.hideHandled': 'Сховати вже опрацьовані',
+ 'dawarich.suggestions.matchesWish': 'У вашому списку бажань: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Додано як місце',
+ 'dawarich.suggestions.acceptedAs.journal': 'У щоденнику',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Бажання позначено виконаним',
+ 'dawarich.suggestions.sourceChanged':
+ 'Це перебування змінилося в Dawarich відтоді, як ви його використали. Написане вами в TREK лишилося недоторканим.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Цього перебування більше немає в Dawarich. Написане вами в TREK лишилося недоторканим.',
+ 'dawarich.sourceStatus.suggested': 'Виявлено, не підтверджено',
+ 'dawarich.confidence.high': 'Упевнене виявлення',
+ 'dawarich.confidence.medium': 'Доволі впевнене виявлення',
+ 'dawarich.confidence.low': 'Непевне виявлення',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Додати це перебування як місце',
+ 'dawarich.accept.title.journal': 'Написати запис у щоденник',
+ 'dawarich.accept.title.bucket_list': 'Позначити бажання виконаним',
+ 'dawarich.accept.confirm.place': 'Додати місце',
+ 'dawarich.accept.confirm.journal': 'Додати запис',
+ 'dawarich.accept.confirm.bucket_list': 'Позначити виконаним',
+ 'dawarich.accept.recorded': 'Записано з {from} до {to}',
+ 'dawarich.accept.duration': '{minutes} хв',
+ 'dawarich.accept.name': 'Назва',
+ 'dawarich.accept.date': 'Дата',
+ 'dawarich.accept.from': 'Прибуття',
+ 'dawarich.accept.to': 'Від’їзд',
+ 'dawarich.accept.trip': 'Подорож',
+ 'dawarich.accept.thisTrip': 'Ця подорож',
+ 'dawarich.accept.pickTrip': 'Виберіть подорож',
+ 'dawarich.accept.day': 'День',
+ 'dawarich.accept.noDay': 'Ще не призначено на день',
+ 'dawarich.accept.journal': 'Щоденник',
+ 'dawarich.accept.pickJournal': 'Виберіть щоденник',
+ 'dawarich.accept.notes': 'Нотатки',
+ 'dawarich.accept.story': 'Ваша історія',
+ 'dawarich.accept.storyPlaceholder': 'Що тут сталося?',
+ 'dawarich.accept.photosHint': 'Додайте фото до запису після його створення.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Додано з ваших записів Dawarich',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Звірити список бажань із Dawarich',
+ 'dawarich.bucket.description':
+ 'Переглядає ваші записи в пошуках місць, до яких ви хотіли дістатися. Для відвідин потрібні і близькість, і проведений час — проїзд повз не рахується.',
+ 'dawarich.bucket.scan': 'Перевірити список бажань',
+ 'dawarich.bucket.scanning': 'Перевірка…',
+ 'dawarich.bucket.noMatches': 'У ваших записах не знайшлося нічого зі списку бажань.',
+ 'dawarich.bucket.alreadyVisited': 'Уже позначено виконаним',
+ 'dawarich.bucket.confirm': 'Позначити виконаними: {count}',
+ 'dawarich.bucket.confirmed': 'Позначено бажань виконаними: {count}',
+ 'dawarich.bucket.skipped': 'Записів без координат, які не вдалося перевірити: {count}.',
+ 'dawarich.bucket.truncated': 'Перевірено лише перші записи. Запустіть ще раз для решти.',
+ 'dawarich.bucket.visitedFrom': 'Позначено виконаним за вашими записами Dawarich',
+ 'dawarich.bucket.clearVisit': 'Скасувати',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Країни з Dawarich',
+ 'dawarich.atlas.description':
+ 'Країни, у яких ви були за вашими записами. Підтвердьте ті, які хочете бачити в Atlas, — нічого не додається саме собою, а позначене вами вручну лишається вашим.',
+ 'dawarich.atlas.load': 'Пошукати країни',
+ 'dawarich.atlas.loading': 'Читання ваших записів…',
+ 'dawarich.atlas.empty': 'У ваших записах немає країн, яких TREK ще не має.',
+ 'dawarich.atlas.cities': 'Міст: {count}',
+ 'dawarich.atlas.citiesOne': '1 місто',
+ 'dawarich.atlas.accept': 'Додати країн: {count}',
+ 'dawarich.atlas.accepted': 'Додано країн: {count}',
+ 'dawarich.atlas.unresolved': 'TREK не зміг зіставити з країною: {names}.',
+ 'dawarich.atlas.source': 'З Dawarich',
+ 'dawarich.atlas.range': 'Переглянуто з {from} до {to}',
+
+ 'dawarich.atlas.trigger': 'Бажання та країни з ваших записів',
+ 'dawarich.atlas.dialogSubtitle': 'Що ваші записи кажуть про ваш Атлас',
+ 'dawarich.atlas.tab.wishes': 'Список бажань',
+ 'dawarich.atlas.tab.countries': 'Країни',
+ 'dawarich.atlas.window': 'Переглянуто останні 12 місяців.',
+ 'dawarich.selected': 'Вибрано: {count}',
+ 'dawarich.again': 'Перевірити знову',
+ 'dawarich.bucket.metersAway': 'за {meters} м',
+ 'dawarich.bucket.kilometersAway': 'за {km} км',
+ 'dawarich.bucket.rule': 'Бажання вважається здійсненим у межах {meters} м і після {minutes} хвилин на місці.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/uk/index.ts
+++ b/shared/src/i18n/uk/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts
index 62bcc7e8a..4534bf99e 100644
--- a/shared/src/i18n/uk/settings.ts
+++ b/shared/src/i18n/uk/settings.ts
@@ -553,6 +553,21 @@ const settings: TranslationStrings = {
'Автоматично показує на карті маршрут для кожного рейсу, поїзда та іншого бронювання — без потреби вмикати це окремо для кожного елемента.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Що цей ключ може читати',
+ 'settings.apiScopes.hint':
+ 'Залиште все увімкненим для ключа, який має бачити геть усе. Усе, що ви вимкнете, для цього ключа буде заборонено, а не просто пропущено у відповіді.',
+ 'settings.apiScopes.all': 'Усе',
+ 'settings.apiScopes.limited': '{count} з {total}',
+ 'settings.apiScopes.trips': 'Подорожі',
+ 'settings.apiScopes.days': 'Дні',
+ 'settings.apiScopes.places': 'Місця',
+ 'settings.apiScopes.notes': 'Нотатки дня',
+ 'settings.apiScopes.reservations': 'Бронювання',
+ 'settings.apiScopes.accommodations': 'Проживання',
+ 'settings.apiScopes.travellers': 'Хто їде',
+ 'settings.apiScopes.bucket-list': 'Список бажань',
+ 'settings.apiScopes.stats': 'Підсумки',
'settings.apiKeys.title': 'Ключі API',
'settings.apiKeys.description': 'Ключі для публічного API, щоб інші програми могли читати ваші подорожі. Лише читання: ключ нічого не змінить і не видалить.',
'settings.apiKeys.create': 'Створити ключ',
diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts
index 846223ce0..af4c3c6f6 100644
--- a/shared/src/i18n/vi/admin.ts
+++ b/shared/src/i18n/vi/admin.ts
@@ -611,6 +611,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': 'Nhập địa điểm từ danh sách Naver Maps được chia sẻ',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': 'Đồng bộ chuyến bay từ máy chủ AirTrail của bạn',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ 'Đọc các điểm lưu trú và lộ trình đã ghi từ phiên bản Dawarich mà mỗi người tự kết nối',
'admin.addons.catalog.llm_parsing.name': 'Phân tích bằng AI',
'admin.addons.catalog.llm_parsing.description': 'Đọc các đặt chỗ mà bộ phân tích tích hợp không hiểu, bằng mô hình AI bạn chọn',
'admin.addons.enabled': 'Đã bật',
diff --git a/shared/src/i18n/vi/dawarich.ts b/shared/src/i18n/vi/dawarich.ts
new file mode 100644
index 000000000..7e71521c6
--- /dev/null
+++ b/shared/src/i18n/vi/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ 'Kết nối phiên bản Dawarich của riêng bạn để xem bạn đã thực sự đi những đâu. TREK đọc dữ liệu đó và gợi ý mục nhật ký, địa điểm và quốc gia — không có gì được thêm vào cho đến khi bạn xác nhận, và không có gì được ghi ngược lại Dawarich.',
+ 'dawarich.url': 'Địa chỉ phiên bản',
+ 'dawarich.apiKey': 'Khóa API',
+ 'dawarich.apiKeyPlaceholder': 'Dán khóa API Dawarich của bạn',
+ 'dawarich.apiKeyHint': 'Tìm trong Dawarich ở mục Tài khoản → Khóa API. Được lưu mã hóa và không hiển thị lại.',
+ 'dawarich.allowInsecureTls': 'Cho phép chứng chỉ tự ký',
+ 'dawarich.allowInsecureTlsHint': 'Chỉ cần khi phiên bản của bạn dùng chứng chỉ mà máy chủ không tin cậy.',
+ 'dawarich.syncEnabled': 'Tự động kiểm tra điểm lưu trú mới',
+ 'dawarich.syncEnabledHint': 'Tắt nghĩa là TREK chỉ đọc Dawarich khi bạn yêu cầu.',
+ 'dawarich.test.button': 'Kiểm tra kết nối',
+ 'dawarich.test.success': 'Đã kết nối. Tìm thấy {count} điểm lưu trú trong 30 ngày qua.',
+ 'dawarich.test.failed': 'Không thể kết nối tới Dawarich.',
+ 'dawarich.syncNow': 'Kiểm tra ngay',
+ 'dawarich.connected': 'Đã kết nối',
+ 'dawarich.notConnected': 'Chưa kết nối',
+ 'dawarich.disconnect': 'Ngắt kết nối',
+ 'dawarich.lastSync': 'Kiểm tra lần cuối {when}',
+ 'dawarich.neverSynced': 'Chưa kiểm tra lần nào',
+ 'dawarich.syncPartial': 'một số chuyến đi không đọc được',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Đã lưu kết nối Dawarich',
+ 'dawarich.toast.saveError': 'Không thể lưu kết nối',
+ 'dawarich.toast.disconnected': 'Đã ngắt kết nối Dawarich',
+ 'dawarich.toast.synced': 'Tìm thấy {count} điểm lưu trú mới',
+ 'dawarich.toast.syncError': 'Không thể đọc Dawarich',
+ 'dawarich.toast.syncRunning': 'Đang có một lượt kiểm tra chạy',
+ 'dawarich.toast.acceptError': 'Không thể thêm mục này',
+ 'dawarich.toast.updateError': 'Không thể cập nhật gợi ý này',
+ 'dawarich.toast.accepted.place': 'Đã thêm vào chuyến đi',
+ 'dawarich.toast.accepted.journal': 'Đã thêm vào nhật ký',
+ 'dawarich.toast.accepted.bucket_list': 'Đã đánh dấu hoàn thành trong danh sách mong muốn',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': 'điểm lưu trú',
+ 'dawarich.capability.track': 'lộ trình đã ghi',
+ 'dawarich.capability.locations': 'đối chiếu danh sách mong muốn',
+ 'dawarich.capability.visitedCities': 'quốc gia và thành phố',
+ 'dawarich.capability.missing': 'Phiên bản Dawarich này không hỗ trợ: {features}.',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK không kết nối được tới địa chỉ đó.',
+ 'dawarich.error.unauthorized': 'Dawarich đã từ chối khóa API.',
+ 'dawarich.error.forbidden': 'Khóa API đó không được phép đọc dữ liệu này.',
+ 'dawarich.error.not_found': 'Phiên bản Dawarich này không có điểm cuối đó.',
+ 'dawarich.error.rate_limited': 'Dawarich yêu cầu TREK chậm lại. Hãy thử lại sau giây lát.',
+ 'dawarich.error.server_error': 'Dawarich đã trả về một lỗi.',
+ 'dawarich.error.invalid_response': 'Địa chỉ đó trả về thứ gì đó không phải Dawarich.',
+ 'dawarich.error.too_large': 'Dawarich gửi nhiều dữ liệu hơn mức TREK đọc được trong một lần.',
+ 'dawarich.error.not_connected': 'Chưa có phiên bản Dawarich nào được kết nối.',
+ 'dawarich.error.addon_disabled': 'Tiện ích Dawarich đang bị tắt trên phiên bản này.',
+ 'dawarich.error.offline': 'Việc này cần kết nối — TREK hiện đang ngoại tuyến.',
+ 'dawarich.error.invalid_url': 'TREK không dùng được địa chỉ này.',
+ 'dawarich.warning.private_ip': 'Địa chỉ này trỏ tới một IP riêng ({ip}). Hãy chắc chắn đó là điều bạn muốn — máy chủ có thể cần ALLOW_INTERNAL_NETWORK=true để truy cập.',
+ 'dawarich.error.unknown': 'Đã xảy ra lỗi khi trao đổi với Dawarich.',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': 'Hiện lộ trình đã ghi',
+ 'dawarich.trail.hide': 'Ẩn lộ trình đã ghi',
+ 'dawarich.trail.loading': 'Đang tải lộ trình đã ghi…',
+ 'dawarich.trail.empty': 'Không có gì được ghi lại trong những ngày này',
+ 'dawarich.trail.offline': 'Lộ trình đã ghi cần có kết nối',
+ 'dawarich.trail.unavailable': 'Không tải được lộ trình đã ghi',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} phút',
+ 'dawarich.duration.hours': '{hours} giờ',
+ 'dawarich.duration.hoursMinutes': '{hours} giờ {minutes} phút',
+ 'dawarich.checkedAgo': 'đã kiểm tra {ago}',
+
+ 'dawarich.badge.lowConfidence': 'Không chắc',
+ 'dawarich.badge.sourceChanged': 'Đã đổi trong Dawarich',
+ 'dawarich.badge.sourceMissing': 'Không còn trong Dawarich',
+
+ 'dawarich.suggestions.title': 'Từ Dawarich',
+ 'dawarich.suggestions.pending': '{count} đang chờ bạn',
+ 'dawarich.suggestions.loading': 'Đang đọc Dawarich…',
+ 'dawarich.suggestions.notConnected': 'Hãy kết nối Dawarich trong Cài đặt để xem các điểm lưu trú của bạn ở đây.',
+ 'dawarich.suggestions.unavailable': 'Không đọc được Dawarich.',
+ 'dawarich.suggestions.allHandled': 'Mọi thứ được ghi lại ở đây đều đã được xử lý.',
+ 'dawarich.suggestions.asJournal': 'Viết một mục nhật ký',
+ 'dawarich.suggestions.asPlace': 'Thêm làm địa điểm',
+ 'dawarich.suggestions.dismiss': 'Không phải nơi tôi đã ghé',
+ 'dawarich.suggestions.dismissed': 'Đã bỏ qua',
+ 'dawarich.suggestions.restore': 'Khôi phục',
+ 'dawarich.suggestions.showHandled': 'Hiện {count} mục đã xử lý',
+ 'dawarich.suggestions.hideHandled': 'Ẩn những mục đã xử lý',
+ 'dawarich.suggestions.matchesWish': 'Có trong danh sách mong muốn: {name}',
+ 'dawarich.suggestions.acceptedAs.place': 'Đã thêm làm địa điểm',
+ 'dawarich.suggestions.acceptedAs.journal': 'Trong nhật ký',
+ 'dawarich.suggestions.acceptedAs.bucket_list': 'Đã đánh dấu điều mong muốn',
+ 'dawarich.suggestions.sourceChanged':
+ 'Điểm lưu trú này đã thay đổi trong Dawarich kể từ khi bạn dùng nó. Những gì bạn viết trong TREK vẫn nguyên vẹn.',
+ 'dawarich.suggestions.sourceMissing':
+ 'Điểm lưu trú này không còn tồn tại trong Dawarich. Những gì bạn viết trong TREK vẫn nguyên vẹn.',
+ 'dawarich.sourceStatus.suggested': 'Đã phát hiện, chưa xác nhận',
+ 'dawarich.confidence.high': 'Phát hiện chắc chắn',
+ 'dawarich.confidence.medium': 'Phát hiện khá chắc chắn',
+ 'dawarich.confidence.low': 'Phát hiện chưa chắc chắn',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': 'Thêm điểm lưu trú này làm địa điểm',
+ 'dawarich.accept.title.journal': 'Viết một mục nhật ký',
+ 'dawarich.accept.title.bucket_list': 'Đánh dấu một điều mong muốn',
+ 'dawarich.accept.confirm.place': 'Thêm địa điểm',
+ 'dawarich.accept.confirm.journal': 'Thêm mục nhập',
+ 'dawarich.accept.confirm.bucket_list': 'Đánh dấu hoàn thành',
+ 'dawarich.accept.recorded': 'Được ghi từ {from} đến {to}',
+ 'dawarich.accept.duration': '{minutes} phút',
+ 'dawarich.accept.name': 'Tên',
+ 'dawarich.accept.date': 'Ngày',
+ 'dawarich.accept.from': 'Đến nơi',
+ 'dawarich.accept.to': 'Rời đi',
+ 'dawarich.accept.trip': 'Chuyến đi',
+ 'dawarich.accept.thisTrip': 'Chuyến đi này',
+ 'dawarich.accept.pickTrip': 'Chọn một chuyến đi',
+ 'dawarich.accept.day': 'Ngày',
+ 'dawarich.accept.noDay': 'Chưa thuộc ngày nào',
+ 'dawarich.accept.journal': 'Nhật ký',
+ 'dawarich.accept.pickJournal': 'Chọn một nhật ký',
+ 'dawarich.accept.notes': 'Ghi chú',
+ 'dawarich.accept.story': 'Câu chuyện của bạn',
+ 'dawarich.accept.storyPlaceholder': 'Chuyện gì đã xảy ra ở đây?',
+ 'dawarich.accept.photosHint': 'Thêm ảnh vào mục nhập sau khi tạo xong.',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': 'Được thêm từ bản ghi Dawarich của bạn',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': 'Đối chiếu danh sách mong muốn với Dawarich',
+ 'dawarich.bucket.description':
+ 'Rà soát các bản ghi của bạn để tìm những nơi bạn muốn đến. Một lần ghé thăm cần cả khoảng cách đủ gần lẫn thời gian ở lại — lái xe ngang qua thì không tính.',
+ 'dawarich.bucket.scan': 'Kiểm tra danh sách mong muốn',
+ 'dawarich.bucket.scanning': 'Đang kiểm tra…',
+ 'dawarich.bucket.noMatches': 'Không có mục nào trong danh sách mong muốn xuất hiện trong các bản ghi của bạn.',
+ 'dawarich.bucket.alreadyVisited': 'Đã đánh dấu hoàn thành',
+ 'dawarich.bucket.confirm': 'Đánh dấu {count} mục',
+ 'dawarich.bucket.confirmed': 'Đã đánh dấu {count} điều mong muốn',
+ 'dawarich.bucket.skipped': '{count} mục không có tọa độ nên không kiểm tra được.',
+ 'dawarich.bucket.truncated': 'Chỉ những mục đầu tiên được kiểm tra. Hãy chạy lại để xử lý phần còn lại.',
+ 'dawarich.bucket.visitedFrom': 'Được đánh dấu từ các bản ghi Dawarich của bạn',
+ 'dawarich.bucket.clearVisit': 'Hoàn tác',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': 'Quốc gia từ Dawarich',
+ 'dawarich.atlas.description':
+ 'Những quốc gia mà các bản ghi của bạn cho thấy bạn đã đặt chân tới. Hãy xác nhận những nơi bạn muốn đưa vào Atlas — không có gì tự động được thêm, và những gì bạn tự đánh dấu vẫn là của bạn.',
+ 'dawarich.atlas.load': 'Tìm quốc gia',
+ 'dawarich.atlas.loading': 'Đang đọc các bản ghi của bạn…',
+ 'dawarich.atlas.empty': 'Các bản ghi của bạn không có quốc gia nào mà TREK chưa biết.',
+ 'dawarich.atlas.cities': '{count} thành phố',
+ 'dawarich.atlas.citiesOne': '1 thành phố',
+ 'dawarich.atlas.accept': 'Thêm {count} quốc gia',
+ 'dawarich.atlas.accepted': 'Đã thêm {count} quốc gia',
+ 'dawarich.atlas.unresolved': 'TREK không khớp được những mục này với quốc gia nào: {names}.',
+ 'dawarich.atlas.source': 'Từ Dawarich',
+ 'dawarich.atlas.range': 'Đã xem từ {from} đến {to}',
+
+ 'dawarich.atlas.trigger': 'Điều ước và quốc gia từ bản ghi của bạn',
+ 'dawarich.atlas.dialogSubtitle': 'Bản ghi của bạn nói gì về Atlas của bạn',
+ 'dawarich.atlas.tab.wishes': 'Danh sách điều ước',
+ 'dawarich.atlas.tab.countries': 'Quốc gia',
+ 'dawarich.atlas.window': 'Đã xem 12 tháng gần nhất.',
+ 'dawarich.selected': 'Đã chọn {count}',
+ 'dawarich.again': 'Kiểm tra lại',
+ 'dawarich.bucket.metersAway': 'cách {meters} m',
+ 'dawarich.bucket.kilometersAway': 'cách {km} km',
+ 'dawarich.bucket.rule': 'Một điều ước được tính là đã đến khi trong vòng {meters} m và sau {minutes} phút tại chỗ.',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/vi/index.ts
+++ b/shared/src/i18n/vi/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts
index f3d2ad0d2..334cd3b31 100644
--- a/shared/src/i18n/vi/settings.ts
+++ b/shared/src/i18n/vi/settings.ts
@@ -554,6 +554,21 @@ const settings: TranslationStrings = {
'Tự động hiển thị trên bản đồ tuyến đường của mỗi chuyến bay, tàu hỏa và đặt chỗ khác, không cần bật riêng từng mục.',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': 'Khóa này được phép đọc gì',
+ 'settings.apiScopes.hint':
+ 'Hãy bật tất cả cho khóa cần xem được mọi thứ. Bất cứ mục nào bạn tắt sẽ bị từ chối với khóa này, chứ không chỉ là bị lược khỏi câu trả lời.',
+ 'settings.apiScopes.all': 'Tất cả',
+ 'settings.apiScopes.limited': '{count} trên {total}',
+ 'settings.apiScopes.trips': 'Chuyến đi',
+ 'settings.apiScopes.days': 'Ngày',
+ 'settings.apiScopes.places': 'Địa điểm',
+ 'settings.apiScopes.notes': 'Ghi chú theo ngày',
+ 'settings.apiScopes.reservations': 'Đặt chỗ',
+ 'settings.apiScopes.accommodations': 'Chỗ ở',
+ 'settings.apiScopes.travellers': 'Ai cùng đi',
+ 'settings.apiScopes.bucket-list': 'Danh sách mong muốn',
+ 'settings.apiScopes.stats': 'Tổng hợp',
'settings.apiKeys.title': 'Khóa API',
'settings.apiKeys.description': 'Khóa cho API công khai, để phần mềm khác có thể đọc các chuyến đi của bạn. Chỉ đọc: khóa không thể thay đổi hay xóa bất cứ thứ gì.',
'settings.apiKeys.create': 'Tạo khóa',
diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts
index 98455514b..7e3ff8461 100644
--- a/shared/src/i18n/zh-TW/admin.ts
+++ b/shared/src/i18n/zh-TW/admin.ts
@@ -603,6 +603,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': '從共享的 Naver 地圖清單匯入地點',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': '從你的 AirTrail 執行個體同步航班',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ '從各位使用者自行連接的 Dawarich 執行個體讀取造訪紀錄與記錄的路線',
'admin.addons.catalog.llm_parsing.name': 'AI 解析',
'admin.addons.catalog.llm_parsing.description': '用你選擇的 AI 模型讀取內建解析器無法識別的訂位',
'admin.addons.enabled': '已啟用',
diff --git a/shared/src/i18n/zh-TW/dawarich.ts b/shared/src/i18n/zh-TW/dawarich.ts
new file mode 100644
index 000000000..15193ca61
--- /dev/null
+++ b/shared/src/i18n/zh-TW/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ '連接你自己的 Dawarich 執行個體,看看你實際去過哪些地方。TREK 會讀取它,並建議日誌條目、地點和國家 — 在你確認之前不會新增任何內容,也不會回寫任何資料到 Dawarich。',
+ 'dawarich.url': '執行個體位址',
+ 'dawarich.apiKey': 'API 金鑰',
+ 'dawarich.apiKeyPlaceholder': '貼上你的 Dawarich API 金鑰',
+ 'dawarich.apiKeyHint': '在 Dawarich 的「帳戶 → API 金鑰」中可以找到。加密儲存,且不會再次顯示。',
+ 'dawarich.allowInsecureTls': '允許自簽憑證',
+ 'dawarich.allowInsecureTlsHint': '只有當你的執行個體使用伺服器不信任的憑證時才需要。',
+ 'dawarich.syncEnabled': '自動檢查新的停留點',
+ 'dawarich.syncEnabledHint': '關閉後,TREK 只會在你要求時才讀取 Dawarich。',
+ 'dawarich.test.button': '測試連線',
+ 'dawarich.test.success': '已連線。最近 30 天找到 {count} 個停留點。',
+ 'dawarich.test.failed': '無法連上 Dawarich。',
+ 'dawarich.syncNow': '立即檢查',
+ 'dawarich.connected': '已連線',
+ 'dawarich.notConnected': '未連線',
+ 'dawarich.disconnect': '中斷連線',
+ 'dawarich.lastSync': '上次檢查於 {when}',
+ 'dawarich.neverSynced': '尚未檢查',
+ 'dawarich.syncPartial': '部分旅行無法讀取',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich 連線已儲存',
+ 'dawarich.toast.saveError': '無法儲存連線',
+ 'dawarich.toast.disconnected': 'Dawarich 已中斷連線',
+ 'dawarich.toast.synced': '找到 {count} 個新的停留點',
+ 'dawarich.toast.syncError': '無法讀取 Dawarich',
+ 'dawarich.toast.syncRunning': '已有一次檢查正在進行',
+ 'dawarich.toast.acceptError': '無法新增這一項',
+ 'dawarich.toast.updateError': '無法更新這則建議',
+ 'dawarich.toast.accepted.place': '已新增至旅行',
+ 'dawarich.toast.accepted.journal': '已新增至日誌',
+ 'dawarich.toast.accepted.bucket_list': '已在願望清單上打勾',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': '停留點',
+ 'dawarich.capability.track': '記錄的路線',
+ 'dawarich.capability.locations': '願望清單比對',
+ 'dawarich.capability.visitedCities': '國家與城市',
+ 'dawarich.capability.missing': '這個 Dawarich 版本不提供:{features}。',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK 無法連上該位址。',
+ 'dawarich.error.unauthorized': 'Dawarich 拒絕了這組 API 金鑰。',
+ 'dawarich.error.forbidden': '這組 API 金鑰沒有讀取這項資料的權限。',
+ 'dawarich.error.not_found': '這個 Dawarich 版本沒有該端點。',
+ 'dawarich.error.rate_limited': 'Dawarich 要求 TREK 放慢速度,請稍後再試。',
+ 'dawarich.error.server_error': 'Dawarich 回應了一個錯誤。',
+ 'dawarich.error.invalid_response': '該位址回應的內容不像是 Dawarich。',
+ 'dawarich.error.too_large': 'Dawarich 傳送的資料超過 TREK 單次可讀取的量。',
+ 'dawarich.error.not_connected': '尚未連接任何 Dawarich 執行個體。',
+ 'dawarich.error.addon_disabled': '這個執行個體已關閉 Dawarich 附加元件。',
+ 'dawarich.error.offline': '這需要連線 — TREK 目前處於離線狀態。',
+ 'dawarich.error.invalid_url': 'TREK 無法使用該位址。',
+ 'dawarich.warning.private_ip': '該位址解析到內網 IP({ip})。請確認這是你要的——伺服器可能需要 ALLOW_INTERNAL_NETWORK=true 才能連到。',
+ 'dawarich.error.unknown': '與 Dawarich 溝通時發生問題。',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': '顯示記錄的路線',
+ 'dawarich.trail.hide': '隱藏記錄的路線',
+ 'dawarich.trail.loading': '正在載入記錄的路線…',
+ 'dawarich.trail.empty': '這幾天沒有任何記錄',
+ 'dawarich.trail.offline': '記錄的路線需要網路連線',
+ 'dawarich.trail.unavailable': '無法載入記錄的路線',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} 分鐘',
+ 'dawarich.duration.hours': '{hours} 小時',
+ 'dawarich.duration.hoursMinutes': '{hours} 小時 {minutes} 分鐘',
+ 'dawarich.checkedAgo': '{ago}檢查過',
+
+ 'dawarich.badge.lowConfidence': '不確定',
+ 'dawarich.badge.sourceChanged': 'Dawarich 中已變更',
+ 'dawarich.badge.sourceMissing': '已從 Dawarich 消失',
+
+ 'dawarich.suggestions.title': '來自 Dawarich',
+ 'dawarich.suggestions.pending': '{count} 項待你處理',
+ 'dawarich.suggestions.loading': '正在讀取 Dawarich…',
+ 'dawarich.suggestions.notConnected': '在「設定」中連接 Dawarich,即可在這裡看到你的停留點。',
+ 'dawarich.suggestions.unavailable': '無法讀取 Dawarich。',
+ 'dawarich.suggestions.allHandled': '這裡記錄的內容都已處理完畢。',
+ 'dawarich.suggestions.asJournal': '寫一篇日誌條目',
+ 'dawarich.suggestions.asPlace': '新增為地點',
+ 'dawarich.suggestions.dismiss': '我沒去過這個地方',
+ 'dawarich.suggestions.dismissed': '已忽略',
+ 'dawarich.suggestions.restore': '放回去',
+ 'dawarich.suggestions.showHandled': '顯示已處理的 {count} 項',
+ 'dawarich.suggestions.hideHandled': '隱藏已處理的項目',
+ 'dawarich.suggestions.matchesWish': '在你的願望清單上:{name}',
+ 'dawarich.suggestions.acceptedAs.place': '已新增為地點',
+ 'dawarich.suggestions.acceptedAs.journal': '已在日誌中',
+ 'dawarich.suggestions.acceptedAs.bucket_list': '願望已打勾',
+ 'dawarich.suggestions.sourceChanged':
+ '自你使用之後,這個停留點在 Dawarich 中已有變動。你在 TREK 中寫下的內容不受影響。',
+ 'dawarich.suggestions.sourceMissing':
+ '這個停留點在 Dawarich 中已不存在。你在 TREK 中寫下的內容不受影響。',
+ 'dawarich.sourceStatus.suggested': '已偵測,尚未確認',
+ 'dawarich.confidence.high': '偵測結果可信',
+ 'dawarich.confidence.medium': '偵測結果尚可信',
+ 'dawarich.confidence.low': '偵測結果不確定',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': '將這個停留點新增為地點',
+ 'dawarich.accept.title.journal': '寫一篇日誌條目',
+ 'dawarich.accept.title.bucket_list': '在願望上打勾',
+ 'dawarich.accept.confirm.place': '新增地點',
+ 'dawarich.accept.confirm.journal': '新增條目',
+ 'dawarich.accept.confirm.bucket_list': '打勾',
+ 'dawarich.accept.recorded': '記錄時間 {from} 至 {to}',
+ 'dawarich.accept.duration': '{minutes} 分鐘',
+ 'dawarich.accept.name': '名稱',
+ 'dawarich.accept.date': '日期',
+ 'dawarich.accept.from': '抵達',
+ 'dawarich.accept.to': '離開',
+ 'dawarich.accept.trip': '旅行',
+ 'dawarich.accept.thisTrip': '這次旅行',
+ 'dawarich.accept.pickTrip': '選擇旅行',
+ 'dawarich.accept.day': '日期',
+ 'dawarich.accept.noDay': '尚未安排在任何一天',
+ 'dawarich.accept.journal': '日誌',
+ 'dawarich.accept.pickJournal': '選擇日誌',
+ 'dawarich.accept.notes': '備註',
+ 'dawarich.accept.story': '你的故事',
+ 'dawarich.accept.storyPlaceholder': '這裡發生了什麼?',
+ 'dawarich.accept.photosHint': '條目建立後即可為它新增照片。',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': '來自你的 Dawarich 紀錄',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': '用 Dawarich 核對你的願望清單',
+ 'dawarich.bucket.description':
+ '在你的記錄中尋找你想去的地方。一次造訪必須同時具備足夠的接近程度和停留時間 — 開車路過不算。',
+ 'dawarich.bucket.scan': '核對願望清單',
+ 'dawarich.bucket.scanning': '核對中…',
+ 'dawarich.bucket.noMatches': '你的記錄中沒有出現願望清單上的任何地方。',
+ 'dawarich.bucket.alreadyVisited': '已打勾',
+ 'dawarich.bucket.confirm': '為 {count} 項打勾',
+ 'dawarich.bucket.confirmed': '已為 {count} 個願望打勾',
+ 'dawarich.bucket.skipped': '{count} 個項目沒有座標,無法核對。',
+ 'dawarich.bucket.truncated': '只核對了前面的項目,再執行一次以處理其餘部分。',
+ 'dawarich.bucket.visitedFrom': '依據你的 Dawarich 記錄打勾',
+ 'dawarich.bucket.clearVisit': '復原',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': '來自 Dawarich 的國家',
+ 'dawarich.atlas.description':
+ '你的記錄顯示你曾去過的國家。確認你想放進 Atlas 的項目 — 不會自動新增任何內容,你手動標記的紀錄也維持不變。',
+ 'dawarich.atlas.load': '尋找國家',
+ 'dawarich.atlas.loading': '正在讀取你的記錄…',
+ 'dawarich.atlas.empty': '你的記錄中沒有 TREK 尚未收錄的國家。',
+ 'dawarich.atlas.cities': '{count} 個城市',
+ 'dawarich.atlas.citiesOne': '1 座城市',
+ 'dawarich.atlas.accept': '新增 {count} 個國家',
+ 'dawarich.atlas.accepted': '已新增 {count} 個國家',
+ 'dawarich.atlas.unresolved': 'TREK 無法將這些對應到國家:{names}。',
+ 'dawarich.atlas.source': '來自 Dawarich',
+ 'dawarich.atlas.range': '查看範圍 {from} 至 {to}',
+
+ 'dawarich.atlas.trigger': '來自紀錄的心願與國家',
+ 'dawarich.atlas.dialogSubtitle': '你的紀錄對 Atlas 說了什麼',
+ 'dawarich.atlas.tab.wishes': '心願清單',
+ 'dawarich.atlas.tab.countries': '國家',
+ 'dawarich.atlas.window': '已查看最近 12 個月。',
+ 'dawarich.selected': '已選擇 {count} 項',
+ 'dawarich.again': '再查一次',
+ 'dawarich.bucket.metersAway': '{meters} 公尺外',
+ 'dawarich.bucket.kilometersAway': '{km} 公里外',
+ 'dawarich.bucket.rule': '在 {meters} 公尺內停留 {minutes} 分鐘以上,才算實現心願。',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts
index d32ec8c27..0ba45b51c 100644
--- a/shared/src/i18n/zh-TW/index.ts
+++ b/shared/src/i18n/zh-TW/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts
index 6c2f6e5e4..2fc286d64 100644
--- a/shared/src/i18n/zh-TW/settings.ts
+++ b/shared/src/i18n/zh-TW/settings.ts
@@ -517,6 +517,21 @@ const settings: TranslationStrings = {
'settings.alwaysShowRoutesHint': '自動在地圖上顯示每個航班、火車及其他預訂的路線,不需要逐一手動開啟。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '這組金鑰可讀取的內容',
+ 'settings.apiScopes.hint':
+ '若希望這組金鑰能看到全部內容,就全部保持開啟。任何你關閉的項目都會對這組金鑰直接拒絕,而不只是從回應中省略。',
+ 'settings.apiScopes.all': '全部',
+ 'settings.apiScopes.limited': '{total} 項中的 {count} 項',
+ 'settings.apiScopes.trips': '旅行',
+ 'settings.apiScopes.days': '日期',
+ 'settings.apiScopes.places': '地點',
+ 'settings.apiScopes.notes': '每日備註',
+ 'settings.apiScopes.reservations': '訂位',
+ 'settings.apiScopes.accommodations': '住宿',
+ 'settings.apiScopes.travellers': '同行者',
+ 'settings.apiScopes.bucket-list': '願望清單',
+ 'settings.apiScopes.stats': '總計',
'settings.apiKeys.title': 'API 金鑰',
'settings.apiKeys.description': '公開 API 的金鑰,讓其他軟體讀取你的行程。唯讀:金鑰無法修改或刪除任何內容。',
'settings.apiKeys.create': '建立金鑰',
diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts
index 85821b276..631a01e34 100644
--- a/shared/src/i18n/zh/admin.ts
+++ b/shared/src/i18n/zh/admin.ts
@@ -563,6 +563,9 @@ const admin: TranslationStrings = {
'admin.addons.catalog.naver_list_import.description': '从共享的 Naver 地图列表导入地点',
'admin.addons.catalog.airtrail.name': 'AirTrail',
'admin.addons.catalog.airtrail.description': '从你的 AirTrail 实例同步航班',
+ 'admin.addons.catalog.dawarich.name': 'Dawarich',
+ 'admin.addons.catalog.dawarich.description':
+ '从每位使用者自行连接的 Dawarich 实例读取到访记录和记录的路线',
'admin.addons.catalog.llm_parsing.name': 'AI 解析',
'admin.addons.catalog.llm_parsing.description': '用你选择的 AI 模型读取内置解析器无法识别的预订',
'admin.addons.enabled': '已启用',
diff --git a/shared/src/i18n/zh/dawarich.ts b/shared/src/i18n/zh/dawarich.ts
new file mode 100644
index 000000000..0ad930c11
--- /dev/null
+++ b/shared/src/i18n/zh/dawarich.ts
@@ -0,0 +1,178 @@
+import type { TranslationStrings } from '../types';
+
+const dawarich: TranslationStrings = {
+ // ── Connection ─────────────────────────────────────────────────────────────
+ 'dawarich.title': 'Dawarich',
+ 'dawarich.intro':
+ '连接你自己的 Dawarich 实例,看看你实际去过哪些地方。TREK 会读取它,并据此推荐日志条目、地点和国家——在你确认之前不会添加任何内容,也不会向 Dawarich 回写任何数据。',
+ 'dawarich.url': '实例地址',
+ 'dawarich.apiKey': 'API 密钥',
+ 'dawarich.apiKeyPlaceholder': '粘贴你的 Dawarich API 密钥',
+ 'dawarich.apiKeyHint': '在 Dawarich 的“账户 → API 密钥”中查看。加密存储,之后不会再次显示。',
+ 'dawarich.allowInsecureTls': '允许自签名证书',
+ 'dawarich.allowInsecureTlsHint': '仅当你的实例使用服务器不信任的证书时才需要。',
+ 'dawarich.syncEnabled': '自动检查新的停留记录',
+ 'dawarich.syncEnabledHint': '关闭后,TREK 只在你主动要求时才读取 Dawarich。',
+ 'dawarich.test.button': '测试连接',
+ 'dawarich.test.success': '已连接。最近 30 天内找到 {count} 条停留记录。',
+ 'dawarich.test.failed': '无法连接到 Dawarich。',
+ 'dawarich.syncNow': '立即检查',
+ 'dawarich.connected': '已连接',
+ 'dawarich.notConnected': '未连接',
+ 'dawarich.disconnect': '断开连接',
+ 'dawarich.lastSync': '上次检查于 {when}',
+ 'dawarich.neverSynced': '尚未检查',
+ 'dawarich.syncPartial': '部分行程无法读取',
+ 'dawarich.serverVersion': 'Dawarich {version}',
+
+ 'dawarich.toast.saved': 'Dawarich 连接已保存',
+ 'dawarich.toast.saveError': '无法保存连接',
+ 'dawarich.toast.disconnected': 'Dawarich 已断开连接',
+ 'dawarich.toast.synced': '找到 {count} 条新的停留记录',
+ 'dawarich.toast.syncError': '无法读取 Dawarich',
+ 'dawarich.toast.syncRunning': '已有一次检查正在进行',
+ 'dawarich.toast.acceptError': '无法添加',
+ 'dawarich.toast.updateError': '无法更新此建议',
+ 'dawarich.toast.accepted.place': '已添加到行程',
+ 'dawarich.toast.accepted.journal': '已添加到旅程日志',
+ 'dawarich.toast.accepted.bucket_list': '已从愿望清单中勾掉',
+
+ // ── What the connected instance can do ─────────────────────────────────────
+ 'dawarich.capability.visits': '停留记录',
+ 'dawarich.capability.track': '记录的路线',
+ 'dawarich.capability.locations': '愿望清单匹配',
+ 'dawarich.capability.visitedCities': '国家和城市',
+ 'dawarich.capability.missing': '此版本的 Dawarich 不提供:{features}。',
+
+ // ── Failure reasons, as sentences the reader can act on ────────────────────
+ 'dawarich.error.unreachable': 'TREK 无法连接到该地址。',
+ 'dawarich.error.unauthorized': 'Dawarich 拒绝了该 API 密钥。',
+ 'dawarich.error.forbidden': '该 API 密钥没有读取此内容的权限。',
+ 'dawarich.error.not_found': '此版本的 Dawarich 没有该接口。',
+ 'dawarich.error.rate_limited': 'Dawarich 要求 TREK 放慢速度。请稍后再试。',
+ 'dawarich.error.server_error': 'Dawarich 返回了一个错误。',
+ 'dawarich.error.invalid_response': '该地址返回的内容不是 Dawarich。',
+ 'dawarich.error.too_large': 'Dawarich 返回的数据超出了 TREK 一次可读取的上限。',
+ 'dawarich.error.not_connected': '尚未连接任何 Dawarich 实例。',
+ 'dawarich.error.addon_disabled': '此实例已关闭 Dawarich 插件。',
+ 'dawarich.error.offline': '这需要联网 — TREK 当前处于离线状态。',
+ 'dawarich.error.invalid_url': 'TREK 无法使用该地址。',
+ 'dawarich.warning.private_ip': '该地址解析到内网 IP({ip})。请确认这是你想要的——服务器可能需要 ALLOW_INTERNAL_NETWORK=true 才能访问。',
+ 'dawarich.error.unknown': '与 Dawarich 通信时出了问题。',
+
+ // ── The recorded route on the map ──────────────────────────────────────────
+ 'dawarich.trail.show': '显示记录的路线',
+ 'dawarich.trail.hide': '隐藏记录的路线',
+ 'dawarich.trail.loading': '正在加载记录的路线…',
+ 'dawarich.trail.empty': '这些日期没有任何记录',
+ 'dawarich.trail.offline': '查看记录的路线需要网络连接',
+ 'dawarich.trail.unavailable': '无法加载记录的路线',
+
+ // ── Suggestions ────────────────────────────────────────────────────────────
+ 'dawarich.duration.minutes': '{minutes} 分钟',
+ 'dawarich.duration.hours': '{hours} 小时',
+ 'dawarich.duration.hoursMinutes': '{hours} 小时 {minutes} 分钟',
+ 'dawarich.checkedAgo': '{ago}检查过',
+
+ 'dawarich.badge.lowConfidence': '不确定',
+ 'dawarich.badge.sourceChanged': 'Dawarich 中已更改',
+ 'dawarich.badge.sourceMissing': '已从 Dawarich 消失',
+
+ 'dawarich.suggestions.title': '来自 Dawarich',
+ 'dawarich.suggestions.pending': '{count} 条待处理',
+ 'dawarich.suggestions.loading': '正在读取 Dawarich…',
+ 'dawarich.suggestions.notConnected': '请在“设置”中连接 Dawarich,以便在这里看到你的停留记录。',
+ 'dawarich.suggestions.unavailable': '无法读取 Dawarich。',
+ 'dawarich.suggestions.allHandled': '这里记录的内容都已处理完毕。',
+ 'dawarich.suggestions.asJournal': '写一篇日志',
+ 'dawarich.suggestions.asPlace': '添加为地点',
+ 'dawarich.suggestions.dismiss': '我没有去过这里',
+ 'dawarich.suggestions.dismissed': '已忽略',
+ 'dawarich.suggestions.restore': '放回来',
+ 'dawarich.suggestions.showHandled': '显示已处理的 {count} 条',
+ 'dawarich.suggestions.hideHandled': '隐藏已处理的内容',
+ 'dawarich.suggestions.matchesWish': '在你的愿望清单上:{name}',
+ 'dawarich.suggestions.acceptedAs.place': '已添加为地点',
+ 'dawarich.suggestions.acceptedAs.journal': '已在旅程日志中',
+ 'dawarich.suggestions.acceptedAs.bucket_list': '心愿已勾掉',
+ 'dawarich.suggestions.sourceChanged':
+ '自你使用之后,这条停留记录在 Dawarich 中已发生变化。你在 TREK 中写下的内容不受影响。',
+ 'dawarich.suggestions.sourceMissing':
+ '这条停留记录在 Dawarich 中已不存在。你在 TREK 中写下的内容不受影响。',
+ 'dawarich.sourceStatus.suggested': '已识别,未确认',
+ 'dawarich.confidence.high': '识别结果可信',
+ 'dawarich.confidence.medium': '识别结果较为可信',
+ 'dawarich.confidence.low': '识别结果不确定',
+
+ // ── The review step ────────────────────────────────────────────────────────
+ 'dawarich.accept.title.place': '将这条停留记录添加为地点',
+ 'dawarich.accept.title.journal': '写一篇日志',
+ 'dawarich.accept.title.bucket_list': '勾掉一个心愿',
+ 'dawarich.accept.confirm.place': '添加地点',
+ 'dawarich.accept.confirm.journal': '添加条目',
+ 'dawarich.accept.confirm.bucket_list': '勾掉它',
+ 'dawarich.accept.recorded': '记录时间:{from} 至 {to}',
+ 'dawarich.accept.duration': '{minutes} 分钟',
+ 'dawarich.accept.name': '名称',
+ 'dawarich.accept.date': '日期',
+ 'dawarich.accept.from': '到达',
+ 'dawarich.accept.to': '离开',
+ 'dawarich.accept.trip': '行程',
+ 'dawarich.accept.thisTrip': '当前行程',
+ 'dawarich.accept.pickTrip': '选择一个行程',
+ 'dawarich.accept.day': '日期安排',
+ 'dawarich.accept.noDay': '尚未安排到某一天',
+ 'dawarich.accept.journal': '旅程日志',
+ 'dawarich.accept.pickJournal': '选择一本日志',
+ 'dawarich.accept.notes': '备注',
+ 'dawarich.accept.story': '你的故事',
+ 'dawarich.accept.storyPlaceholder': '这里发生了什么?',
+ 'dawarich.accept.photosHint': '条目创建后可以再添加照片。',
+
+ // ── A place that came out of a recording ──────────────────────────────────
+ 'dawarich.place.fromDawarich': '来自你的 Dawarich 记录',
+
+ // ── Wishlist ───────────────────────────────────────────────────────────────
+ 'dawarich.bucket.title': '用 Dawarich 核对你的愿望清单',
+ 'dawarich.bucket.description':
+ '在你的记录中查找那些你一直想去的地方。算作到访既要够近,也要待够时间——路过不算。',
+ 'dawarich.bucket.scan': '核对愿望清单',
+ 'dawarich.bucket.scanning': '核对中…',
+ 'dawarich.bucket.noMatches': '你的记录中没有出现愿望清单上的任何地方。',
+ 'dawarich.bucket.alreadyVisited': '已勾掉',
+ 'dawarich.bucket.confirm': '勾掉 {count} 项',
+ 'dawarich.bucket.confirmed': '已勾掉 {count} 个心愿',
+ 'dawarich.bucket.skipped': '有 {count} 条记录没有坐标,无法核对。',
+ 'dawarich.bucket.truncated': '只核对了前面的部分条目。请再运行一次以处理其余内容。',
+ 'dawarich.bucket.visitedFrom': '依据你的 Dawarich 记录勾掉',
+ 'dawarich.bucket.clearVisit': '撤销',
+
+ // ── Atlas ──────────────────────────────────────────────────────────────────
+ 'dawarich.atlas.title': '来自 Dawarich 的国家',
+ 'dawarich.atlas.description':
+ '你的记录显示你到过这些国家。确认你想加入足迹地图的那些——不会自动添加任何内容,你手动标记的记录也依然保留。',
+ 'dawarich.atlas.load': '查找国家',
+ 'dawarich.atlas.loading': '正在读取你的记录…',
+ 'dawarich.atlas.empty': '你的记录中没有出现 TREK 尚未收录的国家。',
+ 'dawarich.atlas.cities': '{count} 座城市',
+ 'dawarich.atlas.citiesOne': '1 座城市',
+ 'dawarich.atlas.accept': '添加 {count} 个国家',
+ 'dawarich.atlas.accepted': '已添加 {count} 个国家',
+ 'dawarich.atlas.unresolved': 'TREK 无法把这些匹配到某个国家:{names}。',
+ 'dawarich.atlas.source': '来自 Dawarich',
+ 'dawarich.atlas.range': '查看范围:{from} 至 {to}',
+
+ 'dawarich.atlas.trigger': '来自记录的心愿与国家',
+ 'dawarich.atlas.dialogSubtitle': '你的记录对足迹地图说了什么',
+ 'dawarich.atlas.tab.wishes': '心愿单',
+ 'dawarich.atlas.tab.countries': '国家',
+ 'dawarich.atlas.window': '已查看最近 12 个月。',
+ 'dawarich.selected': '已选择 {count} 项',
+ 'dawarich.again': '再查一次',
+ 'dawarich.bucket.metersAway': '{meters} 米外',
+ 'dawarich.bucket.kilometersAway': '{km} 公里外',
+ 'dawarich.bucket.rule': '在 {meters} 米以内停留 {minutes} 分钟以上,才算实现心愿。',
+
+};
+
+export default dawarich;
diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts
index 7580137eb..472300ac6 100644
--- a/shared/src/i18n/zh/index.ts
+++ b/shared/src/i18n/zh/index.ts
@@ -1,4 +1,5 @@
import admin from './admin';
+import dawarich from './dawarich';
import airport from './airport';
import atlas from './atlas';
import backup from './backup';
@@ -106,5 +107,6 @@ const locale = {
...mobileSettings,
...mobileCollections,
...storage,
+ ...dawarich,
};
export default locale;
diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts
index a662db141..93df25e5c 100644
--- a/shared/src/i18n/zh/settings.ts
+++ b/shared/src/i18n/zh/settings.ts
@@ -513,6 +513,21 @@ const settings: TranslationStrings = {
'settings.alwaysShowRoutesHint': '自动在地图上显示每个航班、火车和其他预订的路线,无需逐个手动开启。',
// Public API keys (Settings -> Integrations)
+ // ── API keys: what a key may read ─────────────────────────────────────────
+ 'settings.apiScopes.title': '此密钥可读取的内容',
+ 'settings.apiScopes.hint':
+ '如果希望这个密钥看到全部内容,就保持全部开启。任何被关闭的内容对该密钥都会被拒绝,而不只是不出现在结果里。',
+ 'settings.apiScopes.all': '全部',
+ 'settings.apiScopes.limited': '{total} 项中的 {count} 项',
+ 'settings.apiScopes.trips': '行程',
+ 'settings.apiScopes.days': '日程',
+ 'settings.apiScopes.places': '地点',
+ 'settings.apiScopes.notes': '每日备注',
+ 'settings.apiScopes.reservations': '预订',
+ 'settings.apiScopes.accommodations': '住宿',
+ 'settings.apiScopes.travellers': '同行人员',
+ 'settings.apiScopes.bucket-list': '愿望清单',
+ 'settings.apiScopes.stats': '统计总计',
'settings.apiKeys.title': 'API 密钥',
'settings.apiKeys.description': '公开 API 的密钥,让其他软件读取你的行程。只读:密钥无法修改或删除任何内容。',
'settings.apiKeys.create': '创建密钥',
diff --git a/shared/src/index.ts b/shared/src/index.ts
index 89a3f15c8..8fd5ad281 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -33,6 +33,7 @@ export * from './reservation/reservation.schema';
export * from './reservation/ki-reservation.schema';
export * from './datetime/datetime-normalize';
export * from './airtrail/airtrail.schema';
+export * from './dawarich/dawarich.schema';
export * from './day/day.schema';
export * from './day/note-colors';
export * from './assignment/assignment.schema';
diff --git a/shared/src/place/place.schema.ts b/shared/src/place/place.schema.ts
index 43db81962..e202daeb6 100644
--- a/shared/src/place/place.schema.ts
+++ b/shared/src/place/place.schema.ts
@@ -138,6 +138,12 @@ export const placeSchema = z.object({
// The Amap (高德) POI id a place was found by, `amap:`-prefixed. null for every
// place that came from another provider.
amap_poi_id: z.string().nullable().optional(),
+ /**
+ * Where the place came from when it was not typed by a person — currently
+ * only `'dawarich'`, for a stay accepted out of someone's own recordings.
+ * null on every place added by hand, which is nearly all of them.
+ */
+ source: z.string().nullable().optional(),
route_geometry: z.string().nullable().optional(),
// Manual track colour (#776). null = inherit the category colour like before.
route_color: hexColorSchema.nullable().optional(),
diff --git a/shared/src/public-api/public-api.schema.ts b/shared/src/public-api/public-api.schema.ts
index 534b17875..8c92e372e 100644
--- a/shared/src/public-api/public-api.schema.ts
+++ b/shared/src/public-api/public-api.schema.ts
@@ -206,6 +206,82 @@ export const publicApiTripListSchema = z.object({
});
export type PublicApiTripList = z.infer;
+/**
+ * What an integration key is allowed to read.
+ *
+ * These are **not** `include`. `include` picks which sections a response
+ * carries and any caller may ask for any of them; these decide what the key may
+ * see at all, and the server refuses the rest. The two were confused often
+ * enough that it is worth saying plainly: handing an integration a key because
+ * it wanted day notes used to hand it every trip in full.
+ *
+ * The sections mirror the `include` vocabulary so an integrator does not have
+ * to learn two lists, plus the two endpoints that are not a trip section:
+ * `bucket-list` (hangs off the user) and `stats` (aggregates everything, which
+ * is exactly why a narrow key must be able to exclude it).
+ */
+export const PUBLIC_API_SCOPES = [
+ 'trips',
+ 'days',
+ 'places',
+ 'notes',
+ 'reservations',
+ 'accommodations',
+ 'travellers',
+ 'bucket-list',
+ 'stats',
+] as const;
+export type PublicApiScope = (typeof PUBLIC_API_SCOPES)[number];
+
+/**
+ * How a key's access is decided.
+ *
+ * `all` is an explicit value rather than an absent list, because "no list means
+ * everything" is the kind of sentinel where a bug that drops the column grants
+ * full access instead of none. Every key minted before scopes existed is `all`,
+ * which is exactly what it was and still is.
+ */
+export const PUBLIC_API_SCOPE_MODES = ['all', 'limited'] as const;
+export const publicApiScopeModeSchema = z.enum(PUBLIC_API_SCOPE_MODES);
+export type PublicApiScopeMode = z.infer;
+
+/**
+ * What a key may read, resolved.
+ *
+ * `mode: 'all'` carries the full list rather than an empty one, so a consumer
+ * never has to reimplement "empty means everything" — the rule this type exists
+ * to get rid of.
+ */
+export const publicApiGrantSchema = z.object({
+ mode: publicApiScopeModeSchema,
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
+});
+export type PublicApiGrant = z.infer;
+
+/**
+ * Minting an integration key. A key created without `scopes` reads everything,
+ * which is what every key did before this existed — narrowing is opt-in, so no
+ * integration breaks the day the column ships.
+ */
+export const apiTokenCreateRequestSchema = z.object({
+ name: z.string().optional(),
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)).min(1).optional(),
+});
+export type ApiTokenCreateRequest = z.infer;
+
+/** A key as the settings list shows it. The key itself is never in here. */
+export const apiTokenSummarySchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ token_prefix: z.string(),
+ created_at: z.string(),
+ last_used_at: z.string().nullable(),
+ scope_mode: publicApiScopeModeSchema,
+ /** The chosen sections; the full list when the mode is `all`. */
+ scopes: z.array(z.enum(PUBLIC_API_SCOPES)),
+});
+export type ApiTokenSummary = z.infer;
+
/**
* The trip the traveller most recently took, for a dashboard that wants to name it.
*
diff --git a/wiki/Dawarich.md b/wiki/Dawarich.md
new file mode 100644
index 000000000..ffe1a4723
--- /dev/null
+++ b/wiki/Dawarich.md
@@ -0,0 +1,119 @@
+# Dawarich
+
+[Dawarich](https://dawarich.app) is a self-hosted location-history tracker — a Google Timeline replacement you run yourself. It knows exactly where you were. TREK knows what you planned and why. This addon connects the two.
+
+The split is deliberate and it does not move: **Dawarich records, TREK plans and interprets.** TREK never starts a tracker of its own, never writes anything into Dawarich, and never keeps a copy of your GPS archive. The route drawn on a trip is fetched for the window you are looking at and thrown away again.
+
+## What it does
+
+- **Suggests entries from where you actually were.** Dawarich detects the places you stopped at; TREK lists them for review, with the date, the arrival and departure times and how long you stayed.
+- **Turns a reviewed stay into a place, a journal entry, or a ticked wish** — your choice, after you have corrected anything the detector got wrong.
+- **Draws the route you actually took** over a trip's map, coloured per day in your own timezone, alongside the one you planned.
+- **Ticks off your wishlist.** For each entry on your Atlas bucket list with coordinates, TREK can ask your recordings whether you ever got there — and how long you stayed, so driving past does not count.
+- **Offers the countries your recordings say you visited** for your Atlas, without touching what you marked by hand.
+
+Everything arrives as a **suggestion**. Nothing reaches a trip, a journal or your Atlas until you confirm it — a background poll must not edit a trip three other people are planning.
+
+## Setting it up
+
+**An administrator** enables the `dawarich` addon under **Admin → Addons**. It is off by default.
+
+**Each reader** then connects their own instance:
+
+1. In Dawarich, open **Account** and copy your **API key**.
+2. In TREK, go to **Settings → Integrations → Dawarich**.
+3. Paste your instance address (for example `https://dawarich.example.com`) and the key.
+4. Press **Test connection**. TREK reports what it found and which parts of your instance it can read.
+5. Press **Save**.
+
+The key is stored encrypted and never shown again. Leave the field blank when changing the address later — blank means "keep the stored key", as long as the address still points at the same instance. **Moving the connection to a different host clears the key**: a key issued by one Dawarich must not be sent to another one, so retype it after a move. Fixing a typo in the path, or adding a trailing slash, keeps it.
+
+> **A self-signed certificate** on a LAN instance needs the *Allow self-signed certificate* switch. TREK still refuses addresses it cannot resolve or reach; the switch only relaxes the certificate check.
+>
+> **A private address** (`192.168.…`, `10.…`) is accepted with a warning. Your TREK server may additionally need `ALLOW_INTERNAL_NETWORK=true` to reach it.
+
+Each person connects their own Dawarich. One user's recordings are never visible to anyone else on a shared trip.
+
+## Reviewing what was recorded
+
+TREK checks for new stays in the background every fifteen minutes, for trips that are running or recently finished. You can turn that off and pull by hand instead, and **Check now** always works.
+
+Stays appear:
+
+- in the **Places** panel of a trip, for the dates that trip covers
+- in a **journal**, above its timeline
+
+Each one shows what was recorded and how sure Dawarich's detector was. Then:
+
+| Action | What happens |
+|---|---|
+| **Write a journal entry** | Creates a dated entry in the journal you pick, prefilled with the stay's date, time, name and coordinates. Add photos to the entry afterwards, as usual. |
+| **Add as a place** | Creates a place on the trip, optionally pinned to one of its days. Left off a day it joins the trip's shortlist. |
+| **Not a place I visited** | Dismisses it. Reversible — dismissed stays are one click away under *Show already dealt with*. |
+
+Everything is editable before it is saved. A visit detector is right most of the time and confidently wrong the rest, which is exactly why there is a review step and not an import button.
+
+Wishes are not ticked off from here. A wish belongs to the Atlas, is reached once, and is confirmed there — see *Wishlist and Atlas* below.
+
+A place that came out of a recording carries a small Dawarich mark next to its name, so months later it is still clear where it came from. Delete that place and the stay returns to the review list: an acceptance whose result is gone is not an acceptance.
+
+### When Dawarich changes its mind
+
+Dawarich can rename a stay, or delete one, after you have already written a journal entry from it. TREK notices and says so next to the entry — and does nothing else. What you wrote is yours; an integration that rewrote your journal because a detector ran again would be a bug, not a feature.
+
+## The recorded route on the map
+
+Open a trip and press the Dawarich control on the map. The route you actually travelled is drawn dashed and coloured per day, under the planned route so the plan stays readable.
+
+It is fetched live and never stored. If there is nothing to draw, the control says which: nothing was recorded on those dates, the instance could not be reached, or the device is offline.
+
+A journal draws the same thing for its own dates, on the map it already has, when *Show trip tracks* is on.
+
+## Wishlist and Atlas
+
+Both live in the **Atlas**, on the Dawarich panel beside the statistics:
+
+- **Wishlist** asks your recordings about each bucket-list entry that has coordinates. A match needs both closeness and time spent — within 250 m and at least 20 minutes on the spot, so driving past does not count. What it finds is a list you confirm, with the distance, the time spent and the day; entries without coordinates are named as skipped rather than silently ignored. A wish is ticked off on the day it was reached, not on the day you pressed the button, and a tick that came from a recording can be undone on the wishlist itself.
+- **Countries** reads the countries and cities your recordings cover in the last year. Countries already in your Atlas are marked as such, and countries you marked by hand keep their own provenance when confirmed again.
+
+## The other direction
+
+Dawarich can read your TREK trips through the [Public API](Public-API) — a versioned, read-only surface with a key you mint yourself and can narrow to just the sections you want it to see. Nothing is written back to TREK either.
+
+## Which Dawarich versions work
+
+TREK probes your instance when you connect and uses what it finds. A feature your version does not offer is switched off rather than broken, and the connection card names what is missing.
+
+What TREK reads, when the instance offers it:
+
+| Endpoint | Used for |
+|---|---|
+| `GET /api/v1/users/me` | the connection test |
+| `GET /api/v1/visits` | the stays waiting for review |
+| `GET /api/v1/tracks` | the recorded route on the map |
+| `GET /api/v1/points` | the same, on an instance that has not generated tracks yet |
+| `GET /api/v1/locations` | checking your wishlist |
+| `GET /api/v1/countries/visited_cities` | the Atlas countries |
+
+Two details worth knowing, because they shape what TREK can promise:
+
+- **A Dawarich visit carries no "last changed" timestamp.** TREK detects a change by comparing the fields it shows, which is why it can tell you a stay changed but cannot tell you exactly what.
+- **Deleting a visit removes it from the API rather than marking it deleted.** TREK notices by re-reading the whole window, which is why a sync reads a date range rather than only what is new.
+
+## Troubleshooting
+
+| What you see | What it usually means |
+|---|---|
+| *TREK could not reach that address* | Wrong address, instance down, or your TREK server has no route to it. |
+| *Dawarich rejected the API key* | The key was regenerated, or belongs to a different instance. |
+| *That address answered with something that is not Dawarich* | Usually a reverse proxy or an auth portal answering instead. Enter the Dawarich address itself. |
+| *This Dawarich version does not have that endpoint* | An older instance. Everything else keeps working; the connection card lists what is missing. |
+| Connected, but no stays | Dawarich detects visits only with a reverse geocoder configured (Photon, Nominatim, Geoapify or LocationIQ). Check that first. |
+| *some trips could not be read* | Part of the sync succeeded. Press **Check now**; if it persists, look at the reason under the connection. |
+
+## See also
+
+- [Public API](Public-API) — how Dawarich reads your trips
+- [Atlas](Atlas) — the bucket list and the visited-countries map
+- [Journey Journal](Journey-Journal) — where accepted stays become entries
+- [Addons Overview](Addons-Overview) — the full addon table
diff --git a/wiki/Public-API.md b/wiki/Public-API.md
index c10571cad..4cf2baf3d 100644
--- a/wiki/Public-API.md
+++ b/wiki/Public-API.md
@@ -19,7 +19,8 @@ It cannot write anything. An integration that reads your itinerary needs no abil
1. Open **Settings → Integrations**
2. Under **API Keys**, click **Create key** and give it a name you will recognise later (the name is only for you — "Dawarich", "home assistant", "laptop script")
-3. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one.
+3. Choose **what the key may read.** Everything is selected by default. Anything you switch off is refused for that key.
+4. **Copy the key immediately.** It is shown once. TREK stores only a hash of it, so it cannot be shown again — if you lose it, delete the key and make a new one.
Keys look like `trek_` followed by 48 characters. You can hold ten at a time.
@@ -45,6 +46,21 @@ curl -H "X-API-Key: trek_your_key_here" \
Both are equivalent. Use whichever the other side offers.
+## What a key may read
+
+A key can be narrowed when you create it, to any subset of:
+
+`trips` · `days` · `places` · `notes` · `reservations` · `accommodations` · `travellers` · `bucket-list` · `stats`
+
+This is **not** the same as `include`. `include` chooses which sections a response carries and any key may ask for any of them; this decides what the key may see at all, and the server refuses the rest with a `403`. A key given only `days` and `notes` reads exactly that, however it asks.
+
+Two rules make it predictable:
+
+- **A section you named but may not read is a `403`**, with the missing one in `required_scope`. Silently dropping what you explicitly asked for would send you debugging your own correct code.
+- **A section you did not name is simply left out.** `include` defaults to everything, so a narrow key that omits `include` gets what it may have rather than an error for wanting sections it never mentioned.
+
+Keys created before this existed, and keys created with everything selected, read all of it — nothing that worked before stops working.
+
## Endpoints
### `GET /api/v1/trips`
@@ -220,6 +236,7 @@ A Homepage widget then needs no scripting:
|---|---|
| `400` | malformed trip id, or an unknown `include` section |
| `401` | missing, malformed or unknown key — also what you get for a key of the wrong kind |
+| `403` | the key is valid but not allowed to read what was asked for (`code: "API_SCOPE_FORBIDDEN"`, with the missing section in `required_scope`) |
| `404` | no such trip, or not one of yours |
| `429` | rate limit exceeded |
@@ -227,7 +244,7 @@ A Homepage widget then needs no scripting:
## What is not here yet
-- **Writing.** Read-only for now. Write access needs per-scope enforcement that this surface does not implement, and a key that only reads is a much safer thing to hand out.
+- **Writing.** Read-only. A key that can only read is a very different thing to hand to third-party software, and every integration asking for this surface so far wants to read.
- **Incremental sync.** There is no `?since=` filter, because TREK cannot yet answer it truthfully — child records carry no modification timestamp, so a filter on `updated_at` would silently hide trips whose itinerary changed. Fetch the list and compare.
- **Webhooks.** Nothing pushes; poll at a sensible interval.