diff --git a/client/src/api/wanderer.ts b/client/src/api/wanderer.ts index 5d0a15f08..fee1a30e6 100644 --- a/client/src/api/wanderer.ts +++ b/client/src/api/wanderer.ts @@ -1,6 +1,7 @@ import type { AdminFeatureToggleResponse, TourSourceCheckResponse, + TourSourceHintResponse, WandererConnection, WandererImportPreview, WandererImportRequest, @@ -11,6 +12,7 @@ import type { WandererSettingsRequest, WandererTestRequest, WandererTestResponse, + WandererWatch, } from '@trek/shared'; import apiClient from './client'; @@ -31,6 +33,10 @@ export const wandererApi = { disconnect: (): Promise<{ success: true }> => apiClient.delete('/integrations/wanderer/settings').then((r) => r.data), test: (data: WandererTestRequest): Promise => apiClient.post('/integrations/wanderer/test', data, upstream).then((r) => r.data), + /** WAN-02: the optional background watch of the connection, with the open hints. */ + getWatch: (): Promise => apiClient.get('/integrations/wanderer/watch').then((r) => r.data), + setWatch: (enabled: boolean): Promise => + apiClient.put('/integrations/wanderer/watch', { enabled }).then((r) => r.data), search: (tripId: number, body: WandererSearchRequest, signal?: AbortSignal): Promise => apiClient.post(`/trips/${tripId}/tours/wanderer/search`, body, { ...upstream, signal }).then((r) => r.data), @@ -43,6 +49,9 @@ export const wandererApi = { /** MF-03: whether the trail a snapshot came from changed since; the server reads, it writes nothing. */ checkSource: (tripId: number, placeId: number): Promise => apiClient.post(`/trips/${tripId}/tours/wanderer/sources/${placeId}/check`, {}, upstream).then((r) => r.data), + /** WAN-02: the caller's own background hint on a snapshot; the server only reads its own rows. */ + sourceHint: (tripId: number, placeId: number, signal?: AbortSignal): Promise => + apiClient.get(`/trips/${tripId}/tours/wanderer/sources/${placeId}/hint`, { signal }).then((r) => r.data), }; /** The admin's switch for wanderer under the Tours addon. */ diff --git a/client/src/components/Settings/WandererConnectionSection.tsx b/client/src/components/Settings/WandererConnectionSection.tsx index 544c6a742..227f878ff 100644 --- a/client/src/components/Settings/WandererConnectionSection.tsx +++ b/client/src/components/Settings/WandererConnectionSection.tsx @@ -16,11 +16,14 @@ import { } from './settingsKit'; import ToggleSwitch from './ToggleSwitch'; import { useWandererConnection } from './useWandererConnection'; +import WandererWatchRow from './WandererWatchRow'; /** * Settings → Integrations → wanderer: the reader's own trail database, from * which the Tours workspace imports trails. Laid out like the Dawarich and * AirTrail cards beside it; the behaviour is in `useWandererConnection`. + * Under the form, the optional background check of the imported trails + * (WAN-02), which switches on its own and needs no save. */ export default function WandererConnectionSection(): React.ReactElement { const { t } = useTranslation(); @@ -136,6 +139,8 @@ export default function WandererConnectionSection(): React.ReactElement { )} )} + + ); } diff --git a/client/src/components/Settings/WandererWatchRow.test.tsx b/client/src/components/Settings/WandererWatchRow.test.tsx new file mode 100644 index 000000000..ab6d31311 --- /dev/null +++ b/client/src/components/Settings/WandererWatchRow.test.tsx @@ -0,0 +1,101 @@ +import userEvent from '@testing-library/user-event'; +import type { WandererSourceHintItem, WandererWatch } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor, within } from '../../../tests/helpers/render'; +import WandererWatchRow from './WandererWatchRow'; + +const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +const navigate = vi.hoisted(() => vi.fn()); +vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('react-router', async () => ({ + ...(await vi.importActual('react-router')), + useNavigate: () => navigate, +})); + +const off: WandererWatch = { + enabled: false, + connected: true, + lastRunAt: null, + pausedUntil: null, + blocked: false, + hints: [], +}; +const hints: WandererSourceHintItem[] = [ + { + placeId: 7, + tripId: 3, + tripTitle: 'Alps', + tourName: 'Ridge', + state: 'changed', + changes: ['name'], + reason: null, + externalUpdated: '2026-10-14', + checkedAt: '2026-10-20T10:00:00Z', + }, + { + placeId: 8, + tripId: 3, + tripTitle: 'Alps', + tourName: 'Gorge', + state: 'unverifiable', + changes: [], + reason: 'unavailable', + externalUpdated: null, + checkedAt: '2026-10-19T10:00:00Z', + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('WandererWatchRow (WAN-02)', () => { + it('is off by default and dimmed, not hidden, without a connection', async () => { + repo.getWatch.mockResolvedValue({ ...off, connected: false }); + render(); + await waitFor(() => expect(repo.getWatch).toHaveBeenCalled()); + const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false }); + expect(toggle).toHaveAttribute('aria-pressed', 'false'); + expect(toggle.closest('[aria-disabled="true"]')).not.toBeNull(); + expect(screen.queryByTestId('wanderer-watch-status')).toBeNull(); + }); + + it('switches on at once and then says when it last looked', async () => { + repo.getWatch.mockResolvedValue(off); + repo.setWatch.mockResolvedValue({ ...off, enabled: true }); + render(); + const toggle = await screen.findByRole('button', { name: 'Check my trails for changes', pressed: false }); + await waitFor(() => expect(toggle.closest('[aria-disabled="true"]')).toBeNull()); + await userEvent.click(toggle); + expect(repo.setWatch).toHaveBeenCalledWith(true); + expect(await screen.findByText('Not looked yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Hints/ })).toBeDisabled(); + }); + + it('counts the open hints on the button and opens each tour from the dialog', async () => { + repo.getWatch.mockResolvedValue({ ...off, enabled: true, lastRunAt: '2026-10-20T10:00:00Z', hints }); + render(); + expect(await screen.findByTestId('wanderer-hint-count')).toHaveTextContent('2'); + await userEvent.click(screen.getByRole('button', { name: /Hints/ })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Hints on your trails')).toBeInTheDocument(); + expect(within(dialog).getByText('2 open hints')).toBeInTheDocument(); + const ridge = within(dialog).getByTestId('wanderer-hint-7'); + expect(within(ridge).getByText('Changed')).toBeInTheDocument(); + expect(within(ridge).getByText('Name')).toBeInTheDocument(); + expect(within(within(dialog).getByTestId('wanderer-hint-8')).getByText('Trail out of reach')).toBeInTheDocument(); + + await userEvent.click(ridge); + expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7'); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('names a refused token instead of a last look', async () => { + repo.getWatch.mockResolvedValue({ ...off, enabled: true, blocked: true }); + render(); + expect(await screen.findByText('Token refused')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/Settings/WandererWatchRow.tsx b/client/src/components/Settings/WandererWatchRow.tsx new file mode 100644 index 000000000..aebc1b874 --- /dev/null +++ b/client/src/components/Settings/WandererWatchRow.tsx @@ -0,0 +1,97 @@ +import { BellRing } from 'lucide-react'; +import React from 'react'; +import { useTranslation } from '../../i18n'; +import { fs } from '../shared/DialogShell'; +import { Tooltip } from '../shared/Tooltip'; +import { InfoDot } from '../Tours/tourParts'; +import WandererHintsDialog from '../Tours/wanderer/WandererHintsDialog'; +import { SETTINGS_BUTTON, SettingRow, SettingRows, StatusPill } from './settingsKit'; +import ToggleSwitch from './ToggleSwitch'; +import { useWandererWatch, type WandererWatchController } from './useWandererWatch'; + +/** The switch, dimmed with a word on why while there is no connection to watch with. */ +function WatchSwitch({ W }: { W: WandererWatchController }) { + const { t } = useTranslation(); + const toggle = ( + void W.toggle()} label={t('wanderer.watch.label')} /> + ); + if (W.available) return toggle; + return ( + + + {toggle} + + + ); +} + +/** The open hints behind a button, their number as a badge on its corner; dimmed while there are none. */ +function HintsButton({ W }: { W: WandererWatchController }) { + const { t } = useTranslation(); + const count = W.hints.length; + return ( + + + + + + ); +} + +/** + * WAN-02 in the wanderer card: the optional background check of the user's + * own imported trails. Off until switched on; while on, what it last did and + * its open hints, which open in a dialog that leads to each tour. + */ +export default function WandererWatchRow({ connected }: { connected: boolean }): React.ReactElement { + const { t } = useTranslation(); + const W = useWandererWatch(connected); + return ( + + + {t('wanderer.watch.label')} + + + } + hint={t('wanderer.watch.hint')} + dimmed={W.saving} + control={} + > + {W.status && ( +
+ + + {W.status.text} + + + + + +
+ )} +
+ +
+ ); +} diff --git a/client/src/components/Settings/notificationLabels.ts b/client/src/components/Settings/notificationLabels.ts index a60e2fec0..367ad4ee1 100644 --- a/client/src/components/Settings/notificationLabels.ts +++ b/client/src/components/Settings/notificationLabels.ts @@ -27,6 +27,7 @@ export const EVENT_LABEL_KEYS: Record = { photos_shared: 'settings.notifyPhotosShared', collab_message: 'settings.notifyCollabMessage', packing_tagged: 'settings.notifyPackingTagged', + wanderer_source_hint: 'settings.notifyWandererSourceHint', version_available: 'settings.notifyVersionAvailable', } diff --git a/client/src/components/Settings/useWandererWatch.test.ts b/client/src/components/Settings/useWandererWatch.test.ts new file mode 100644 index 000000000..c88b4a09b --- /dev/null +++ b/client/src/components/Settings/useWandererWatch.test.ts @@ -0,0 +1,134 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { WandererSourceHintItem, WandererWatch } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useWandererWatch, watchStatusOf } from './useWandererWatch'; + +const repo = vi.hoisted(() => ({ getWatch: vi.fn(), setWatch: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +const navigate = vi.hoisted(() => vi.fn()); +vi.mock('../../repo/wandererRepo', () => ({ wandererRepo: repo })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => (params ? `${key} ${JSON.stringify(params)}` : key), + locale: 'en', + }), +})); +vi.mock('react-router', async () => ({ + ...(await vi.importActual('react-router')), + useNavigate: () => navigate, +})); + +const t = (key: string) => key; +const off: WandererWatch = { + enabled: false, + connected: true, + lastRunAt: null, + pausedUntil: null, + blocked: false, + hints: [], +}; +const hint: WandererSourceHintItem = { + placeId: 7, + tripId: 3, + tripTitle: 'Alps', + tourName: 'Ridge', + state: 'possible_change', + changes: [], + reason: null, + externalUpdated: '2026-10-14', + checkedAt: '2026-10-20T10:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + repo.getWatch.mockResolvedValue(off); + repo.setWatch.mockImplementation(async (enabled: boolean) => ({ ...off, enabled })); +}); + +describe('watchStatusOf (WAN-02)', () => { + const now = Date.parse('2026-10-20T12:00:00Z'); + + it('says nothing while the watch is off', () => { + expect(watchStatusOf(null, t, 'en', now)).toBeNull(); + expect(watchStatusOf(off, t, 'en', now)).toBeNull(); + }); + + it('names a refused token first, then a pause still running, then the last look', () => { + const on = { ...off, enabled: true }; + expect(watchStatusOf({ ...on, blocked: true, pausedUntil: '2026-10-21T00:00:00Z' }, t, 'en', now)).toEqual({ + tone: 'danger', + text: 'wanderer.watch.blocked', + why: 'wanderer.watch.blockedHint', + }); + expect(watchStatusOf({ ...on, pausedUntil: '2026-10-20T14:00:00Z' }, t, 'en', now)).toMatchObject({ + tone: 'warning', + text: 'wanderer.watch.paused', + why: 'wanderer.watch.pausedHint', + }); + expect( + watchStatusOf({ ...on, pausedUntil: '2026-10-20T11:00:00Z', lastRunAt: '2026-10-20T10:00:00Z' }, t, 'en', now) + ).toEqual({ tone: 'neutral', text: 'wanderer.watch.lastRun', why: null }); + expect(watchStatusOf(on, t, 'en', now)).toEqual({ tone: 'neutral', text: 'wanderer.watch.neverRun', why: null }); + }); +}); + +describe('useWandererWatch (WAN-02)', () => { + it('loads with the card and again when the connection comes or goes', async () => { + const { result, rerender } = renderHook(({ connected }) => useWandererWatch(connected), { + initialProps: { connected: true }, + }); + await waitFor(() => expect(result.current.available).toBe(true)); + expect(result.current.enabled).toBe(false); + expect(result.current.status).toBeNull(); + rerender({ connected: false }); + await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2)); + }); + + it('switches at once, and says so either way', async () => { + const { result } = renderHook(() => useWandererWatch(true)); + await waitFor(() => expect(result.current.available).toBe(true)); + await act(async () => { + await result.current.toggle(); + }); + expect(repo.setWatch).toHaveBeenCalledWith(true); + expect(result.current.enabled).toBe(true); + expect(toast.success).toHaveBeenCalledWith('wanderer.watch.on'); + + repo.setWatch.mockRejectedValueOnce(new Error('down')); + await act(async () => { + await result.current.toggle(); + }); + expect(toast.error).toHaveBeenCalledWith('wanderer.watch.failed'); + expect(result.current.enabled).toBe(true); + }); + + it('leaves the switch alone without a connection, or when the watch could not be read', async () => { + repo.getWatch.mockResolvedValueOnce({ ...off, connected: false }); + const { result } = renderHook(() => useWandererWatch(false)); + await waitFor(() => expect(repo.getWatch).toHaveBeenCalled()); + await act(async () => { + await result.current.toggle(); + }); + expect(repo.setWatch).not.toHaveBeenCalled(); + + repo.getWatch.mockRejectedValueOnce(new Error('offline')); + const failed = renderHook(() => useWandererWatch(true)); + await waitFor(() => expect(repo.getWatch).toHaveBeenCalledTimes(2)); + expect(failed.result.current.available).toBe(false); + }); + + it('lists the hints and opens the tour one is about', async () => { + repo.getWatch.mockResolvedValue({ ...off, enabled: true, hints: [hint] }); + const { result } = renderHook(() => useWandererWatch(true)); + await waitFor(() => expect(result.current.hints).toEqual([hint])); + act(() => result.current.openHints()); + expect(result.current.hintsOpen).toBe(true); + act(() => result.current.openTour(hint)); + expect(navigate).toHaveBeenCalledWith('/trips/3?tab=plan&place=7'); + expect(result.current.hintsOpen).toBe(false); + act(() => result.current.openHints()); + act(() => result.current.closeHints()); + expect(result.current.hintsOpen).toBe(false); + }); +}); diff --git a/client/src/components/Settings/useWandererWatch.ts b/client/src/components/Settings/useWandererWatch.ts new file mode 100644 index 000000000..1ef183f86 --- /dev/null +++ b/client/src/components/Settings/useWandererWatch.ts @@ -0,0 +1,113 @@ +import type { WandererSourceHintItem, WandererWatch } from '@trek/shared'; +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { useTranslation } from '../../i18n'; +import { wandererRepo } from '../../repo/wandererRepo'; +import { useToast } from '../shared/Toast'; +import { hintWhen } from '../Tours/wanderer/sourceHintParts'; + +type Translate = (key: string, params?: Record) => string; + +/** What the watch last did, as one pill: refused, waiting after errors, last looked, or not yet. */ +interface WandererWatchStatus { + tone: 'neutral' | 'warning' | 'danger'; + text: string; + /** Why, for the tooltip; null where the text says it all. */ + why: string | null; +} + +/** The pill for a switched-on watch; null while it is off, since then nothing runs to report on. */ +export function watchStatusOf( + watch: WandererWatch | null, + t: Translate, + locale: string, + now = Date.now() +): WandererWatchStatus | null { + if (!watch?.enabled) return null; + if (watch.blocked) return { tone: 'danger', text: t('wanderer.watch.blocked'), why: t('wanderer.watch.blockedHint') }; + const pausedUntil = watch.pausedUntil ? new Date(watch.pausedUntil).getTime() : Number.NaN; + if (pausedUntil > now) { + return { + tone: 'warning', + text: t('wanderer.watch.paused', { time: hintWhen(watch.pausedUntil, locale) }), + why: t('wanderer.watch.pausedHint'), + }; + } + const lastRun = hintWhen(watch.lastRunAt, locale); + return { + tone: 'neutral', + text: lastRun ? t('wanderer.watch.lastRun', { date: lastRun }) : t('wanderer.watch.neverRun'), + why: null, + }; +} + +/** + * WAN-02: the background watch in the wanderer card. It loads with the card + * and again whenever the connection comes or goes, switches on and off at + * once (there is nothing to save with the form), and opens the tour a hint is + * about. Online-only like the rest of the connection: the watch runs on the + * server and its hints are read from there. + */ +export function useWandererWatch(connected: boolean) { + const { t, locale } = useTranslation(); + const toast = useToast(); + const navigate = useNavigate(); + const [watch, setWatch] = useState(null); + const [saving, setSaving] = useState(false); + const [hintsOpen, setHintsOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + wandererRepo.getWatch().then( + (next) => { + if (!cancelled) setWatch(next); + }, + () => { + // The card above already says when the connection cannot be read; without + // an answer the switch simply stays dimmed. + if (!cancelled) setWatch(null); + } + ); + return () => { + cancelled = true; + }; + }, [connected]); + + const toggle = useCallback(async () => { + if (!watch?.connected || saving) return; + setSaving(true); + try { + const next = await wandererRepo.setWatch(!watch.enabled); + setWatch(next); + toast.success(next.enabled ? t('wanderer.watch.on') : t('wanderer.watch.off')); + } catch { + toast.error(t('wanderer.watch.failed')); + } finally { + setSaving(false); + } + }, [saving, t, toast, watch]); + + const openTour = useCallback( + (hint: WandererSourceHintItem) => { + setHintsOpen(false); + navigate(`/trips/${hint.tripId}?tab=plan&place=${hint.placeId}`); + }, + [navigate] + ); + + return { + /** The switch can act: the server knows a connection to watch with. */ + available: !!watch?.connected, + enabled: !!watch?.enabled, + saving, + status: watchStatusOf(watch, t, locale), + hints: watch?.hints ?? [], + hintsOpen, + openHints: useCallback(() => setHintsOpen(true), []), + closeHints: useCallback(() => setHintsOpen(false), []), + openTour, + toggle, + }; +} + +export type WandererWatchController = ReturnType; diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx index 2ecff0e2b..c82c6e0aa 100644 --- a/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx +++ b/client/src/components/Tours/provenance/TourProvenanceSection.test.tsx @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import type { TourProvenance, TourSourceCheckResponse } from '@trek/shared'; +import type { TourProvenance, TourSourceCheckResponse, TourSourceHint } from '@trek/shared'; import { describe, expect, it, vi } from 'vitest'; import TourProvenanceSection from './TourProvenanceSection'; import type { SourceCheck } from './useSourceCheck'; @@ -158,4 +158,51 @@ describe('TourProvenanceSection (P2C-02, MF-03)', () => { expect(screen.getByText('tours.provenance.body.unavailable')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /tours.provenance.reimport/ })).toBeNull(); }); + + it('shows the background hint with its own check button until the source answered (WAN-02)', () => { + const onCheck = vi.fn(); + const wanderer: TourProvenance = { + ...base, + origin: 'wanderer', + source: { + kind: 'wanderer', + instance: 'https://t.example', + externalId: 't1', + externalUrl: null, + externalUpdated: null, + importedAt: null, + }, + }; + const hint: TourSourceHint = { + state: 'changed', + changes: ['description'], + reason: null, + externalUpdated: '2026-10-14', + checkedAt: '2026-10-20T10:00:00Z', + }; + const { rerender } = render( + + ); + const notice = screen.getByTestId('tour-source-hint'); + expect(notice).toHaveTextContent('wanderer.watch.noticeLabel'); + expect(screen.getByTestId('source-hint-state')).toHaveAttribute('data-state', 'changed'); + expect(screen.getByText('tours.provenance.change.description')).toBeInTheDocument(); + const buttons = screen.getAllByRole('button', { name: /tours.provenance.check$/ }); + expect(buttons).toHaveLength(1); + fireEvent.click(buttons[0]); + expect(onCheck).toHaveBeenCalledTimes(1); + + rerender( + + ); + expect(screen.queryByTestId('tour-source-hint')).toBeNull(); + expect(screen.getAllByRole('button', { name: /tours.provenance.check$/ })).toHaveLength(1); + + rerender(); + expect(screen.queryByTestId('tour-source-hint')).toBeNull(); + }); }); diff --git a/client/src/components/Tours/provenance/TourProvenanceSection.tsx b/client/src/components/Tours/provenance/TourProvenanceSection.tsx index d522069a2..775f1a7da 100644 --- a/client/src/components/Tours/provenance/TourProvenanceSection.tsx +++ b/client/src/components/Tours/provenance/TourProvenanceSection.tsx @@ -1,4 +1,4 @@ -import type { TourListItem, TourProvenance, TourSourceCheckResponse } from '@trek/shared'; +import type { TourListItem, TourProvenance, TourSourceCheckResponse, TourSourceHint } from '@trek/shared'; import { CopyPlus, Download, ExternalLink, FileDown, Loader2, PenLine, RefreshCw } from 'lucide-react'; import type { ReactNode } from 'react'; import { useTranslation } from '../../../i18n'; @@ -9,8 +9,10 @@ import ConfirmDialog from '../../shared/ConfirmDialog'; import { DialogSection, fs } from '../../shared/DialogShell'; import { Tooltip } from '../../shared/Tooltip'; import { CardAction, InfoDot } from '../tourParts'; +import { hintWhen, SourceHintPills, SourceHintTile } from '../wanderer/sourceHintParts'; import WandererMark from '../wanderer/WandererMark'; import { type SourceCheck, useSourceCheck } from './useSourceCheck'; +import { useSourceHint } from './useSourceHint'; import { useTourProvenance } from './useTourProvenance'; type Translate = (key: string, params?: Record) => string; @@ -177,10 +179,73 @@ function CheckResult({ check, locale, t }: { check: SourceCheck; locale: string; ); } +/** The check button, in the head of the section or on the hint it answers. */ +function CheckButton({ check, t }: { check: SourceCheck; t: Translate }) { + return ( + + + : + } + onClick={() => void check.check()} + disabled={check.checking} + > + {t('tours.provenance.check')} + + + + ); +} + +/** + * WAN-02: what the reader's background watch found about this snapshot's + * source. A hint, never the answer: it leads to the check, which reads the + * source in full and from which a change can be taken in. + */ +function HintNotice({ + hint, + check, + locale, + t, +}: { + hint: TourSourceHint; + check: SourceCheck; + locale: string; + t: Translate; +}) { + const when = hintWhen(hint.checkedAt, locale); + return ( +
+ +
+ + {t('wanderer.watch.noticeLabel')} + +
+ + {when && ( + + {t('wanderer.watch.checkedAt', { date: when })} + + )} +
+
+ +
+ ); +} + interface TourProvenanceSectionProps { provenance: TourProvenance; /** Given for a wanderer snapshot the user may change: the check of its source and the reimport. */ check?: SourceCheck; + /** WAN-02: the reader's own background hint on the source, shown until the check answers. */ + hint?: TourSourceHint | null; } /** @@ -189,13 +254,15 @@ interface TourProvenanceSectionProps { * came in. For a wanderer snapshot, MF-03: the check of its source, on purpose * and read only, and from a change the confirmed reimport as a new snapshot. */ -export default function TourProvenanceSection({ provenance, check }: TourProvenanceSectionProps) { +export default function TourProvenanceSection({ provenance, check, hint }: TourProvenanceSectionProps) { const { t, locale } = useTranslation(); const { source } = provenance; const imported = source.kind !== 'trek'; const importedAt = dayOf((source.kind === 'wanderer' ? source.importedAt : null) ?? provenance.createdAt, locale); const externalUpdated = source.kind === 'wanderer' ? dayOf(source.externalUpdated, locale) : null; const local = ; + // The hint carries the check button until the source answered a check, which spends it. + const openHint = check && hint && !check.answered ? hint : null; const sourceIcon = source.kind === 'gpx' ? ( @@ -209,27 +276,7 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena - - - ) : ( - - ) - } - onClick={() => void check.check()} - disabled={check.checking} - > - {t('tours.provenance.check')} - - - - ) - } + action={check && !openHint && } >
@@ -267,6 +314,7 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena {local} )} + {check && openHint && } {check && }
{check && ( @@ -287,7 +335,8 @@ export default function TourProvenanceSection({ provenance, check }: TourProvena /** * The section as the tour's detail places it: reads the provenance with the * detail, and offers the source check only on a wanderer snapshot the user - * may change, where a reimport has somewhere to go. + * may change, where a reimport has somewhere to go, with the reader's own + * background hint on it (WAN-02). */ export function TourProvenanceBlock({ tripId, @@ -302,7 +351,15 @@ export function TourProvenanceBlock({ }) { const provenance = useTourProvenance(tripId, tour); const check = useSourceCheck({ tripId, placeId: tour.place_id, onReimported }); + const fromWanderer = (provenance?.origin ?? tour.origin) === 'wanderer'; + const hint = useSourceHint({ tripId, placeId: tour.place_id, enabled: canCheck && fromWanderer }); if (!provenance) return null; const checkable = canCheck && provenance.origin === 'wanderer'; - return ; + return ( + + ); } diff --git a/client/src/components/Tours/provenance/useSourceCheck.test.ts b/client/src/components/Tours/provenance/useSourceCheck.test.ts index 4568d32c5..49843cb40 100644 --- a/client/src/components/Tours/provenance/useSourceCheck.test.ts +++ b/client/src/components/Tours/provenance/useSourceCheck.test.ts @@ -100,4 +100,33 @@ describe('useSourceCheck (MF-03)', () => { rerender({ placeId: 8 }); expect(result.current.result).toBeNull(); }); + + it('remembers that the source answered for this tour, through a reimport, and not for another (WAN-02)', async () => { + const onReimported = vi.fn(); + const { result, rerender } = renderHook(({ placeId }) => useSourceCheck({ tripId: 1, placeId, onReimported }), { + initialProps: { placeId: 7 }, + }); + expect(result.current.answered).toBe(false); + repo.checkSource.mockRejectedValueOnce({ code: 'unreachable' }); + await act(async () => { + await result.current.check(); + }); + expect(result.current.answered).toBe(false); + + await act(async () => { + await result.current.check(); + }); + expect(result.current.answered).toBe(true); + act(() => result.current.askReimport()); + await act(async () => { + await result.current.reimport(); + }); + expect(result.current.result).toBeNull(); + expect(result.current.answered).toBe(true); + + rerender({ placeId: 8 }); + expect(result.current.answered).toBe(false); + act(() => result.current.askReimport()); + expect(result.current.answered).toBe(false); + }); }); diff --git a/client/src/components/Tours/provenance/useSourceCheck.ts b/client/src/components/Tours/provenance/useSourceCheck.ts index 0a7c9b02f..041b96e14 100644 --- a/client/src/components/Tours/provenance/useSourceCheck.ts +++ b/client/src/components/Tours/provenance/useSourceCheck.ts @@ -25,19 +25,35 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt const [reimporting, setReimporting] = useState(false); // The answer and the question belong to the tour they were asked for: // another tour shown in the same dialog starts unchecked. - const [state, setState] = useState<{ placeId: number; result: TourSourceCheckResponse | null; confirming: boolean }>({ - placeId, - result: null, - confirming: false, - }); + // `answered` stays once the source answered for this tour, through a reimport + // too: the server then holds no background hint on it any more (WAN-02). + const [state, setState] = useState<{ + placeId: number; + result: TourSourceCheckResponse | null; + confirming: boolean; + answered: boolean; + }>({ placeId, result: null, confirming: false, answered: false }); const result = state.placeId === placeId ? state.result : null; const confirming = state.placeId === placeId && state.confirming; + const answered = state.placeId === placeId && state.answered; const setResult = useCallback( - (next: TourSourceCheckResponse | null) => setState({ placeId, result: next, confirming: false }), + (next: TourSourceCheckResponse | null) => + setState((current) => ({ + placeId, + result: next, + confirming: false, + answered: (current.placeId === placeId && current.answered) || next !== null, + })), [placeId] ); const setConfirming = useCallback( - (next: boolean) => setState((current) => ({ ...current, placeId, confirming: next })), + (next: boolean) => + setState((current) => ({ + ...current, + placeId, + confirming: next, + answered: current.placeId === placeId && current.answered, + })), [placeId] ); @@ -76,6 +92,8 @@ export function useSourceCheck({ tripId, placeId, onReimported }: SourceCheckOpt checking, result, check, + /** The source answered a check of this tour; a background hint on it is spent. */ + answered, /** A reimport can be offered: the trail was read and differs, and someone takes the new snapshot in. */ canReimport: !!result?.reimport && !!onReimported, confirming, diff --git a/client/src/components/Tours/provenance/useSourceHint.test.ts b/client/src/components/Tours/provenance/useSourceHint.test.ts new file mode 100644 index 000000000..1bd555b18 --- /dev/null +++ b/client/src/components/Tours/provenance/useSourceHint.test.ts @@ -0,0 +1,47 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { TourSourceHint } from '@trek/shared'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSourceHint } from './useSourceHint'; + +const repo = vi.hoisted(() => ({ sourceHint: vi.fn() })); +vi.mock('../../../repo/wandererRepo', () => ({ wandererRepo: repo })); + +const hint: TourSourceHint = { + state: 'possible_change', + changes: [], + reason: null, + externalUpdated: '2026-10-14', + checkedAt: '2026-10-20T10:00:00Z', +}; + +beforeEach(() => { + vi.clearAllMocks(); + repo.sourceHint.mockResolvedValue(hint); +}); + +describe('useSourceHint (WAN-02)', () => { + it('reads the hint of the tour it is asked for, and asks again for another tour', async () => { + const { result, rerender } = renderHook(({ placeId }) => useSourceHint({ tripId: 1, placeId, enabled: true }), { + initialProps: { placeId: 7 }, + }); + expect(result.current).toBeNull(); + await waitFor(() => expect(result.current).toEqual(hint)); + expect(repo.sourceHint).toHaveBeenCalledWith(1, 7, expect.any(AbortSignal)); + + repo.sourceHint.mockResolvedValue(null); + rerender({ placeId: 8 }); + expect(result.current).toBeNull(); + await waitFor(() => expect(repo.sourceHint).toHaveBeenCalledWith(1, 8, expect.any(AbortSignal))); + }); + + it('asks nothing where the hint could lead nowhere, and shows none after a failed read', async () => { + const off = renderHook(() => useSourceHint({ tripId: 1, placeId: 7, enabled: false })); + expect(off.result.current).toBeNull(); + expect(repo.sourceHint).not.toHaveBeenCalled(); + + repo.sourceHint.mockRejectedValue(new Error('down')); + const failed = renderHook(() => useSourceHint({ tripId: 1, placeId: 7, enabled: true })); + await waitFor(() => expect(repo.sourceHint).toHaveBeenCalled()); + expect(failed.result.current).toBeNull(); + }); +}); diff --git a/client/src/components/Tours/provenance/useSourceHint.ts b/client/src/components/Tours/provenance/useSourceHint.ts new file mode 100644 index 000000000..dc38572af --- /dev/null +++ b/client/src/components/Tours/provenance/useSourceHint.ts @@ -0,0 +1,39 @@ +import type { TourSourceHint } from '@trek/shared'; +import { useEffect, useState } from 'react'; +import { wandererRepo } from '../../../repo/wandererRepo'; + +/** + * WAN-02: the reader's own background hint on a wanderer snapshot, read with + * the tour's detail when it can lead anywhere (a snapshot the reader may + * check). Null while it loads, offline, on a failed read and when the watch + * left none: a missing hint only means there is nothing to point at, never + * that the trail is unchanged. + */ +export function useSourceHint({ + tripId, + placeId, + enabled, +}: { + tripId: number; + placeId: number; + enabled: boolean; +}): TourSourceHint | null { + const [state, setState] = useState<{ key: string; hint: TourSourceHint | null } | null>(null); + const key = `${tripId}|${placeId}`; + + useEffect(() => { + if (!enabled) return; + const controller = new AbortController(); + wandererRepo.sourceHint(tripId, placeId, controller.signal).then( + (hint) => { + if (!controller.signal.aborted) setState({ key, hint }); + }, + () => { + if (!controller.signal.aborted) setState({ key, hint: null }); + } + ); + return () => controller.abort(); + }, [enabled, key, tripId, placeId]); + + return enabled && state?.key === key ? state.hint : null; +} diff --git a/client/src/components/Tours/wanderer/WandererHintsDialog.tsx b/client/src/components/Tours/wanderer/WandererHintsDialog.tsx new file mode 100644 index 000000000..7d1541dbe --- /dev/null +++ b/client/src/components/Tours/wanderer/WandererHintsDialog.tsx @@ -0,0 +1,119 @@ +import type { WandererSourceHintItem } from '@trek/shared'; +import { ChevronRight } from 'lucide-react'; +import { useId } from 'react'; +import { useTranslation } from '../../../i18n'; +import { + DialogButton, + DialogFooter, + DialogHeader, + DialogShell, + DialogTile, + FooterSpacer, + fs, + NEUTRAL_TINT, +} from '../../shared/DialogShell'; +import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts'; +import WandererMark from './WandererMark'; + +/** One hint: the tour and its trip, what the watch found, when, and the way to the tour. */ +function HintRow({ hint, onOpen }: { hint: WandererSourceHintItem; onOpen: (hint: WandererSourceHintItem) => void }) { + const { t, locale } = useTranslation(); + const when = hintWhen(hint.checkedAt, locale); + return ( +
  • + +
  • + ); +} + +/** + * WAN-02: the open hints of the user's wanderer watch, each leading to its + * tour, where the source check shows what really changed. Reading only: the + * dialog changes nothing, and a hint goes once its source was checked. + */ +export default function WandererHintsDialog({ + open, + hints, + onClose, + onOpenTour, +}: { + open: boolean; + hints: readonly WandererSourceHintItem[]; + onClose: () => void; + onOpenTour: (hint: WandererSourceHintItem) => void; +}) { + const { t } = useTranslation(); + const labelId = useId(); + return ( + + + + } + tint={NEUTRAL_TINT} + labelId={labelId} + onClose={onClose} + title={t('wanderer.watch.dialogTitle')} + sub={t('wanderer.watch.hintsCount', { count: hints.length })} + /> + } + footer={ + + + {t('common.close')} + + } + > + {hints.length === 0 ? ( +

    + {t('wanderer.watch.empty')} +

    + ) : ( +
      + {hints.map((hint) => ( + + ))} +
    + )} +
    + ); +} diff --git a/client/src/components/Tours/wanderer/sourceHintParts.test.tsx b/client/src/components/Tours/wanderer/sourceHintParts.test.tsx new file mode 100644 index 000000000..906aac131 --- /dev/null +++ b/client/src/components/Tours/wanderer/sourceHintParts.test.tsx @@ -0,0 +1,24 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { hintWhen, SourceHintPills, SourceHintTile } from './sourceHintParts'; + +// Rendered without the translation provider: every text reads as its key. +describe('sourceHintParts (WAN-02)', () => { + it('names a possible change apart from a proven one, with the reason a source could not be judged', () => { + const { rerender } = render(); + expect(screen.getByTestId('source-hint-state')).toHaveAttribute('data-state', 'possible_change'); + expect(screen.getByText('wanderer.watch.state.possible_change')).toBeInTheDocument(); + + rerender(); + expect(screen.getByText('wanderer.watch.state.unverifiable')).toBeInTheDocument(); + expect(screen.getByText('wanderer.watch.reason.no_change_time')).toBeInTheDocument(); + }); + + it('marks the tile with the state, and reads a stored instant or nothing', () => { + const { container } = render(); + expect(container.querySelector('.text-warning')).not.toBeNull(); + expect(hintWhen(null, 'en')).toBeNull(); + expect(hintWhen('not a time', 'en')).toBeNull(); + expect(hintWhen('2026-10-20T10:00:00Z', 'en')).toEqual(expect.stringContaining('Oct')); + }); +}); diff --git a/client/src/components/Tours/wanderer/sourceHintParts.tsx b/client/src/components/Tours/wanderer/sourceHintParts.tsx new file mode 100644 index 000000000..52365a1b8 --- /dev/null +++ b/client/src/components/Tours/wanderer/sourceHintParts.tsx @@ -0,0 +1,63 @@ +import type { TourSourceHint, TourSourceHintState } from '@trek/shared'; +import { HelpCircle, History, PenLine, type LucideIcon } from 'lucide-react'; +import { useTranslation } from '../../../i18n'; +import { SoftPill } from '../../Planner/planParts'; +import { Tooltip } from '../../shared/Tooltip'; +import WandererMark from './WandererMark'; + +/** + * WAN-02: how a hint of the wanderer watch looks wherever it shows (the + * settings list, a tour's provenance): one tone and one icon per state, so a + * possible change never reads like a proven one. + */ +const HINT_LOOK: Record = + { + possible_change: { tone: 'info', text: 'text-info', Icon: History }, + changed: { tone: 'warning', text: 'text-warning', Icon: PenLine }, + unverifiable: { tone: 'neutral', text: 'text-content-muted', Icon: HelpCircle }, + }; + +/** The state as a pill with its meaning in the tooltip, then what the record proves changed or why it could not judge. */ +export function SourceHintPills({ hint }: { hint: Pick }) { + const { t } = useTranslation(); + const look = HINT_LOOK[hint.state]; + return ( + <> + + + }> + {t(`wanderer.watch.state.${hint.state}`)} + + + + {hint.changes.map((part) => ( + {t(`tours.provenance.change.${part}`)} + ))} + {hint.reason && {t(`wanderer.watch.reason.${hint.reason}`)}} + + ); +} + +/** The wanderer emblem on a raised round tile, with the state's icon as a badge on its corner. */ +export function SourceHintTile({ state }: { state: TourSourceHintState }) { + const look = HINT_LOOK[state]; + return ( + + + + + ); +} + +/** A stored instant as the reader's day and time; the hints say when the watch last looked. */ +export function hintWhen(iso: string | null, locale: string): string | null { + if (!iso) return null; + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); +} diff --git a/client/src/pages/tripPlanner/usePlaceDeepLink.test.ts b/client/src/pages/tripPlanner/usePlaceDeepLink.test.ts new file mode 100644 index 000000000..02ae2e3d7 --- /dev/null +++ b/client/src/pages/tripPlanner/usePlaceDeepLink.test.ts @@ -0,0 +1,44 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { usePlaceDeepLink } from './usePlaceDeepLink'; + +function setup(query: string, placeIds: number[]) { + const searchParams = new URLSearchParams(query); + const setSearchParams = vi.fn((update: (params: URLSearchParams) => URLSearchParams) => update(searchParams)); + const selectPlace = vi.fn(); + const hook = renderHook( + ({ ids }) => + usePlaceDeepLink({ + searchParams, + setSearchParams: setSearchParams as never, + placeIds: ids, + selectPlace, + }), + { initialProps: { ids: placeIds } } + ); + return { searchParams, setSearchParams, selectPlace, hook }; +} + +describe('usePlaceDeepLink (WAN-02)', () => { + it('opens the linked place of the trip and leaves the URL', () => { + const { searchParams, selectPlace, setSearchParams } = setup('tab=plan&place=7', [3, 7]); + expect(selectPlace).toHaveBeenCalledWith(7); + expect(searchParams.has('place')).toBe(false); + expect(searchParams.get('tab')).toBe('plan'); + expect(setSearchParams).toHaveBeenCalledWith(expect.any(Function), { replace: true }); + }); + + it('waits for the places, then drops an id the trip does not hold', () => { + const { searchParams, selectPlace, hook } = setup('place=9', []); + expect(searchParams.get('place')).toBe('9'); + hook.rerender({ ids: [3, 7] }); + expect(selectPlace).not.toHaveBeenCalled(); + expect(searchParams.has('place')).toBe(false); + }); + + it('does nothing without a link', () => { + const { setSearchParams, selectPlace } = setup('tab=plan', [3]); + expect(setSearchParams).not.toHaveBeenCalled(); + expect(selectPlace).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/pages/tripPlanner/usePlaceDeepLink.ts b/client/src/pages/tripPlanner/usePlaceDeepLink.ts new file mode 100644 index 000000000..4bcacda8e --- /dev/null +++ b/client/src/pages/tripPlanner/usePlaceDeepLink.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import type { PlannerSearchParams } from './plannerTypes'; + +interface PlaceDeepLinkOptions { + searchParams: PlannerSearchParams[0]; + setSearchParams: PlannerSearchParams[1]; + /** The places of this trip, empty until they are loaded. */ + placeIds: readonly number[]; + selectPlace: (placeId: number) => void; +} + +/** + * `?place=` opens that place of the trip, a tour in its detail: the link a + * notification about one of them carries (WAN-02). It waits until the trip's + * places are there, then leaves the URL, so a reload or the way back does not + * open it again. An id the trip does not hold is dropped without a word. + */ +export function usePlaceDeepLink({ searchParams, setSearchParams, placeIds, selectPlace }: PlaceDeepLinkOptions): void { + const requested = searchParams.get('place'); + const known = requested !== null && placeIds.includes(Number(requested)); + const loaded = placeIds.length > 0; + + useEffect(() => { + if (requested === null || (!known && !loaded)) return; + if (known) selectPlace(Number(requested)); + setSearchParams( + (params) => { + params.delete('place'); + return params; + }, + { replace: true } + ); + }, [requested, known, loaded, selectPlace, setSearchParams]); +} diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index 1ad85a89e..dfd997452 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -48,6 +48,7 @@ import { usePlaceLanguage } from '../../hooks/usePlaceLanguage' import { useDayDelete } from './useDayDelete' import { useDayAdd } from './useDayAdd' import { usePlannerDialogs } from './usePlannerDialogs' +import { usePlaceDeepLink } from './usePlaceDeepLink' import { usePlannerMapView } from './usePlannerMapView' import { useMapPlaces } from './useMapPlaces' import { useRoadtripFeed } from './useRoadtripFeed' @@ -308,6 +309,12 @@ export function useTripPlanner() { // takes the request once, then hands it back so a later opening stays plain. const [stayPickerDayId, setStayPickerDayId] = useState(null) const plannerDialogs = usePlannerDialogs({ tripId, tripActions, searchParams, setSearchParams }) + // Only this trip's: the store may still hold the last trip's places for a moment. + const allPlaceIds = useMemo( + () => allPlaces.filter(place => place.trip_id === tripId).map(place => place.id), + [allPlaces, tripId], + ) + usePlaceDeepLink({ searchParams, setSearchParams, placeIds: allPlaceIds, selectPlace: setSelectedPlaceId }) // The dialog state this hook reads further down as well as returning it. const { setShowPlaceForm, editingPlace, setEditingPlace, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, diff --git a/client/src/repo/wandererRepo.test.ts b/client/src/repo/wandererRepo.test.ts index 90f5fb11c..e3e4b9f39 100644 --- a/client/src/repo/wandererRepo.test.ts +++ b/client/src/repo/wandererRepo.test.ts @@ -65,6 +65,27 @@ describe('wandererRepo', () => { ).rejects.toBeInstanceOf(WandererOfflineError); }); + it('WAN-02: reads and switches the watch, and a snapshot hint only while online', async () => { + const watch = { enabled: true, connected: true, lastRunAt: null, pausedUntil: null, blocked: false, hints: [] }; + const sent: unknown[] = []; + const hint = { state: 'possible_change', changes: [], reason: null, externalUpdated: null, checkedAt: 'x' }; + server.use( + http.get('/api/integrations/wanderer/watch', () => HttpResponse.json(watch)), + http.put('/api/integrations/wanderer/watch', async ({ request }) => { + sent.push(await request.json()); + return HttpResponse.json(watch); + }), + http.get('/api/trips/3/tours/wanderer/sources/70/hint', () => HttpResponse.json({ hint })) + ); + await expect(wandererRepo.getWatch()).resolves.toEqual(watch); + await wandererRepo.setWatch(false); + expect(sent).toEqual([{ enabled: false }]); + await expect(wandererRepo.sourceHint(3, 70)).resolves.toEqual(hint); + + vi.mocked(isEffectivelyOffline).mockReturnValue(true); + await expect(wandererRepo.sourceHint(3, 70)).resolves.toBeNull(); + }); + it('RS-03: reads the reason of a failed call from the server, the offline case or nothing', () => { expect(wandererErrorCode(new WandererOfflineError())).toBe('offline'); expect(wandererErrorCode({ response: { data: { code: 'not_own_trail' } } })).toBe('not_own_trail'); diff --git a/client/src/repo/wandererRepo.ts b/client/src/repo/wandererRepo.ts index ae3de9f42..637b093e8 100644 --- a/client/src/repo/wandererRepo.ts +++ b/client/src/repo/wandererRepo.ts @@ -14,6 +14,8 @@ export const wandererRepo = { saveSettings: wandererApi.saveSettings, disconnect: wandererApi.disconnect, test: wandererApi.test, + getWatch: wandererApi.getWatch, + setWatch: wandererApi.setWatch, async search(tripId: number, body: WandererSearchRequest, signal?: AbortSignal) { if (isEffectivelyOffline()) throw new WandererOfflineError(); @@ -30,6 +32,12 @@ export const wandererRepo = { return await wandererApi.checkSource(tripId, placeId); }, + /** WAN-02: the caller's own background hint on a snapshot; offline there is none to show. */ + async sourceHint(tripId: number, placeId: number, signal?: AbortSignal) { + if (isEffectivelyOffline()) return null; + return (await wandererApi.sourceHint(tripId, placeId, signal)).hint; + }, + async importTrail(tripId: number, body: WandererImportRequest) { if (isEffectivelyOffline()) throw new WandererOfflineError(); const result = await wandererApi.importTrail(tripId, body); diff --git a/client/tests/helpers/msw/handlers/tours.ts b/client/tests/helpers/msw/handlers/tours.ts index bf3485cc3..6859f801b 100644 --- a/client/tests/helpers/msw/handlers/tours.ts +++ b/client/tests/helpers/msw/handlers/tours.ts @@ -1,4 +1,9 @@ -import { tourPackingLinkResponseSchema, tourVisitListResponseSchema } from '@trek/shared'; +import { + tourPackingLinkResponseSchema, + tourSourceHintResponseSchema, + tourVisitListResponseSchema, + wandererWatchSchema, +} from '@trek/shared'; import { contractHandler } from '../contract'; export const toursHandlers = [ @@ -10,4 +15,19 @@ export const toursHandlers = [ contractHandler('get', '/api/trips/:id/tours/:placeId/packing-link', { response: tourPackingLinkResponseSchema }, () => ({ packing_link: null, })), + // WAN-02: the wanderer watch is off and leaves no hint on a tour unless a test says so. + contractHandler('get', '/api/integrations/wanderer/watch', { response: wandererWatchSchema }, () => ({ + enabled: false, + connected: false, + lastRunAt: null, + pausedUntil: null, + blocked: false, + hints: [], + })), + contractHandler( + 'get', + '/api/trips/:id/tours/wanderer/sources/:placeId/hint', + { response: tourSourceHintResponseSchema }, + () => ({ hint: null }) + ), ]; diff --git a/server/scripts/repository-owners.json b/server/scripts/repository-owners.json index 0d577ca13..016362bbf 100644 --- a/server/scripts/repository-owners.json +++ b/server/scripts/repository-owners.json @@ -115,6 +115,7 @@ "TourObstacleReviews": "tours", "TourStationVisits": "tours", "TourPackingLinks": "tours", + "TourSourceHints": "tours", "TourSources": "tours", "TourTypes": "tours", "TourWaypoints": "tours", diff --git a/server/src/db/entities/TourSourceHints.entity.ts b/server/src/db/entities/TourSourceHints.entity.ts new file mode 100644 index 000000000..39ad1fa9d --- /dev/null +++ b/server/src/db/entities/TourSourceHints.entity.ts @@ -0,0 +1,53 @@ +import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; +import { TourSourceHintsRepository } from '../repositories/TourSourceHints.repository'; +import { TourSources } from './TourSources.entity'; +import { Users } from './Users.entity'; + +export class TourSourceHints { + [EntityRepositoryType]?: TourSourceHintsRepository; + [PrimaryKeyProp]?: 'place'; + place?: Ref | null; + place_id?: number | null; + user!: Ref; + user_id!: number; + state?: TTourSourceHintsState | null; + changes?: string | null; + reason?: TTourSourceHintsReason | null; + external_updated?: string | null; + seen_hash?: string | null; + seen_updated?: string | null; + checked_at?: string | null; +} + +export const TourSourceHintsState = { + POSSIBLE_CHANGE: 'possible_change', + CHANGED: 'changed', + UNVERIFIABLE: 'unverifiable', +} as const; + +export type TTourSourceHintsState = (typeof TourSourceHintsState)[keyof typeof TourSourceHintsState]; + +export const TourSourceHintsReason = { + UNAVAILABLE: 'unavailable', + NO_CHANGE_TIME: 'no_change_time', +} as const; + +export type TTourSourceHintsReason = (typeof TourSourceHintsReason)[keyof typeof TourSourceHintsReason]; + +export const TourSourceHintsSchema = defineEntity({ + class: TourSourceHints, + repository: () => TourSourceHintsRepository, + properties: { + place: () => p.oneToOne(TourSources).primary().ref().name('place_id').deleteRule('cascade').nullable().hidden(), + place_id: p.integer().nullable().persist(false), + user: () => p.manyToOne(Users).ref().deleteRule('cascade').hidden().index('idx_tour_source_hints_user_id'), + user_id: p.integer().persist(false).index('idx_tour_source_hints_user_id'), + state: p.enum(() => TourSourceHintsState).nullable(), + changes: p.text().nullable(), + reason: p.enum(() => TourSourceHintsReason).nullable(), + external_updated: p.text().nullable(), + seen_hash: p.text().nullable(), + seen_updated: p.text().nullable(), + checked_at: p.text().nullable(), + }, +}); diff --git a/server/src/db/entities/TourSources.entity.ts b/server/src/db/entities/TourSources.entity.ts index f04fc3e03..cd13deae8 100644 --- a/server/src/db/entities/TourSources.entity.ts +++ b/server/src/db/entities/TourSources.entity.ts @@ -1,5 +1,6 @@ import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core'; import { TourSourcesRepository } from '../repositories/TourSources.repository'; +import { TourSourceHints } from './TourSourceHints.entity'; import { DbTimestampType } from '../types'; import { Tours } from './Tours.entity'; import { Users } from './Users.entity'; @@ -19,6 +20,7 @@ export class TourSources { imported_at?: string | null; imported_by?: number | null; importedByRef?: Ref | null; + tour_source_hints: Ref | null = null; } export const TourSourcesSchema = defineEntity({ @@ -43,5 +45,6 @@ export const TourSourcesSchema = defineEntity({ imported_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), imported_by: p.integer().nullable().persist(false).index('idx_tour_sources_imported_by'), importedByRef: () => p.manyToOne(Users).ref().joinColumn('imported_by').nullable().hidden().index('idx_tour_sources_imported_by'), + tour_source_hints: () => p.oneToOne(TourSourceHints).ref().mappedBy('place').hidden(), }, }); diff --git a/server/src/db/entities/Users.entity.ts b/server/src/db/entities/Users.entity.ts index 89be77f28..13b88a051 100644 --- a/server/src/db/entities/Users.entity.ts +++ b/server/src/db/entities/Users.entity.ts @@ -55,6 +55,7 @@ import { TodoItems } from './TodoItems.entity'; import { TourActivities } from './TourActivities.entity'; import { TourActivityExclusions } from './TourActivityExclusions.entity'; import { TourPackingLinks } from './TourPackingLinks.entity'; +import { TourSourceHints } from './TourSourceHints.entity'; import { TourSources } from './TourSources.entity'; import { TrekPhotos } from './TrekPhotos.entity'; import { TripAlbumLinks } from './TripAlbumLinks.entity'; @@ -186,6 +187,7 @@ export class Users { tour_activities_collection = new Collection(this); tour_activity_exclusions_collection = new Collection(this); tour_packing_links_collection = new Collection(this); + tour_source_hints_collection = new Collection(this); tour_sources_collection = new Collection(this); trek_photos_collection = new Collection(this); trip_album_links_collection = new Collection(this); @@ -335,6 +337,7 @@ export const UsersSchema = defineEntity({ tour_activities_collection: () => p.oneToMany(TourActivities).mappedBy('assignedByRef').hidden(), tour_activity_exclusions_collection: () => p.oneToMany(TourActivityExclusions).mappedBy('user').hidden(), tour_packing_links_collection: () => p.oneToMany(TourPackingLinks).mappedBy('linkedByRef').hidden(), + tour_source_hints_collection: () => p.oneToMany(TourSourceHints).mappedBy('user').hidden(), tour_sources_collection: () => p.oneToMany(TourSources).mappedBy('importedByRef').hidden(), trek_photos_collection: () => p.oneToMany(TrekPhotos).mappedBy('owner').hidden(), trip_album_links_collection: () => p.oneToMany(TripAlbumLinks).mappedBy('user').hidden(), diff --git a/server/src/db/entities/WandererConnections.entity.ts b/server/src/db/entities/WandererConnections.entity.ts index 3ce7a226f..209485aa7 100644 --- a/server/src/db/entities/WandererConnections.entity.ts +++ b/server/src/db/entities/WandererConnections.entity.ts @@ -16,6 +16,10 @@ export class WandererConnections { last_check_error?: string | null; created_at?: string | null; updated_at?: string | null; + watch_enabled: number & Opt = 0; + watch_last_run_at?: string | null; + watch_failures: number & Opt = 0; + watch_paused_until?: string | null; } export const WandererConnectionsSchema = defineEntity({ @@ -32,5 +36,9 @@ export const WandererConnectionsSchema = defineEntity({ last_check_error: p.text().nullable(), created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), updated_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`), + watch_enabled: p.integer().default(0), + watch_last_run_at: p.text().nullable(), + watch_failures: p.integer().default(0), + watch_paused_until: p.text().nullable(), }, }); diff --git a/server/src/db/entities/index.ts b/server/src/db/entities/index.ts index c22f963eb..f628f3b7f 100644 --- a/server/src/db/entities/index.ts +++ b/server/src/db/entities/index.ts @@ -113,6 +113,7 @@ import { TourActivityExclusionsSchema } from './TourActivityExclusions.entity'; import { TourChangeStepsSchema } from './TourChangeSteps.entity'; import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity'; import { TourPackingLinksSchema } from './TourPackingLinks.entity'; +import { TourSourceHintsSchema } from './TourSourceHints.entity'; import { TourSourcesSchema } from './TourSources.entity'; import { TourStationVisitsSchema } from './TourStationVisits.entity'; import { TourTypesSchema } from './TourTypes.entity'; @@ -263,6 +264,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [ TourChangeStepsSchema, TourObstacleReviewsSchema, TourPackingLinksSchema, + TourSourceHintsSchema, TourSourcesSchema, TourStationVisitsSchema, TourTypesSchema, diff --git a/server/src/db/json-columns.ts b/server/src/db/json-columns.ts index b0218eb72..8187120da 100644 --- a/server/src/db/json-columns.ts +++ b/server/src/db/json-columns.ts @@ -10,6 +10,7 @@ import { paddleRegionGraphStatsSchema, tourChangeSchema, tourFixedLegSchema, + tourSourceHintChangeSchema, tourWaypointSchema, trackMarksSchema, type Ferrata, @@ -22,6 +23,7 @@ import { type PaddleRegionGraphStats, type TourChange, type TourFixedLeg, + type TourSourceHintChange, type TourWaypoint, type TrackMarks, } from '@trek/shared'; @@ -278,3 +280,14 @@ export const COLLECTION_TOUR_WAYPOINTS: JsonColumn = { schema: z.array(tourWaypointSchema), fallback: () => [], }; + +/** + * WAN-02: the parts of a trail the background watch found proven changed in + * its record, for one snapshot's hint. A broken row names none; the hint's + * state still says that something changed. + */ +export const TOUR_SOURCE_HINT_CHANGES: JsonColumn = { + column: 'tour_source_hints.changes', + schema: z.array(tourSourceHintChangeSchema), + fallback: () => [], +}; diff --git a/server/src/db/kysely/TourSourceHints.table.ts b/server/src/db/kysely/TourSourceHints.table.ts new file mode 100644 index 000000000..ff89bef7a --- /dev/null +++ b/server/src/db/kysely/TourSourceHints.table.ts @@ -0,0 +1,15 @@ +// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand: +// change the migration, then run `npm run gen:db-types`. +import type { InsertOptional } from './columns'; + +export interface TourSourceHintsTable { + place_id: InsertOptional; + user_id: number; + state: InsertOptional; + changes: InsertOptional; + reason: InsertOptional; + external_updated: InsertOptional; + seen_hash: InsertOptional; + seen_updated: InsertOptional; + checked_at: InsertOptional; +} diff --git a/server/src/db/kysely/WandererConnections.table.ts b/server/src/db/kysely/WandererConnections.table.ts index 4d417ef88..e44313c7b 100644 --- a/server/src/db/kysely/WandererConnections.table.ts +++ b/server/src/db/kysely/WandererConnections.table.ts @@ -12,4 +12,8 @@ export interface WandererConnectionsTable { last_check_error: InsertOptional; created_at: InsertOptional; updated_at: InsertOptional; + watch_enabled: InsertOptional; + watch_last_run_at: InsertOptional; + watch_failures: InsertOptional; + watch_paused_until: InsertOptional; } diff --git a/server/src/db/kysely/db.ts b/server/src/db/kysely/db.ts index b90c5fdb7..c5b70a3da 100644 --- a/server/src/db/kysely/db.ts +++ b/server/src/db/kysely/db.ts @@ -119,6 +119,7 @@ import type { TourActivityExclusionsTable } from './TourActivityExclusions.table import type { TourChangeStepsTable } from './TourChangeSteps.table'; import type { TourObstacleReviewsTable } from './TourObstacleReviews.table'; import type { TourPackingLinksTable } from './TourPackingLinks.table'; +import type { TourSourceHintsTable } from './TourSourceHints.table'; import type { TourSourcesTable } from './TourSources.table'; import type { TourStationVisitsTable } from './TourStationVisits.table'; import type { TourTypesTable } from './TourTypes.table'; @@ -275,6 +276,7 @@ export interface DB { tour_change_steps: TourChangeStepsTable; tour_obstacle_reviews: TourObstacleReviewsTable; tour_packing_links: TourPackingLinksTable; + tour_source_hints: TourSourceHintsTable; tour_sources: TourSourcesTable; tour_station_visits: TourStationVisitsTable; tour_types: TourTypesTable; diff --git a/server/src/db/migrations/Migration20200101046120_a_wanderer_connection_can_watch_its_sources.ts b/server/src/db/migrations/Migration20200101046120_a_wanderer_connection_can_watch_its_sources.ts new file mode 100644 index 000000000..a2eea8541 --- /dev/null +++ b/server/src/db/migrations/Migration20200101046120_a_wanderer_connection_can_watch_its_sources.ts @@ -0,0 +1,43 @@ +import { Migration } from '@mikro-orm/migrations'; + +/** + * WAN-02: a user may let TREK look now and then whether the wanderer trails + * they imported moved on since. + * + * `wanderer_connections` gets the switch, off for every existing connection, + * and what the background look needs to stay polite: when it last ran, how + * many runs in a row failed and until when it waits before it asks the + * instance again. + * + * `tour_source_hints` holds what a look found about one snapshot's source, for + * the user who imported it: the state (NULL while there is nothing to say, + * which is never a claim that nothing changed), the parts the trail's record + * proves changed, the reason a source could not be judged, the change time + * the trail said at the look, and what the user last saw of the trail in a + * manual check, which later looks compare against instead of the import. A + * hint never touches the tour; it goes with its snapshot and with its user. + */ +export class Migration20200101046120_a_wanderer_connection_can_watch_its_sources extends Migration { + override name = 'Migration20200101046120_a_wanderer_connection_can_watch_its_sources'; + + override up(): void { + this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0'); + this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_last_run_at TEXT'); + this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_failures INTEGER NOT NULL DEFAULT 0'); + this.addSql('ALTER TABLE wanderer_connections ADD COLUMN watch_paused_until TEXT'); + this.addSql(` + CREATE TABLE IF NOT EXISTS tour_source_hints ( + place_id INTEGER PRIMARY KEY REFERENCES tour_sources(place_id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + state TEXT CHECK (state IS NULL OR state IN ('possible_change', 'changed', 'unverifiable')), + changes TEXT, + reason TEXT CHECK (reason IS NULL OR reason IN ('unavailable', 'no_change_time')), + external_updated TEXT, + seen_hash TEXT, + seen_updated TEXT, + checked_at TEXT + ) + `); + this.addSql('CREATE INDEX IF NOT EXISTS idx_tour_source_hints_user_id ON tour_source_hints(user_id)'); + } +} diff --git a/server/src/db/repositories/TourSourceHints.repository.ts b/server/src/db/repositories/TourSourceHints.repository.ts new file mode 100644 index 000000000..e92c06a56 --- /dev/null +++ b/server/src/db/repositories/TourSourceHints.repository.ts @@ -0,0 +1,210 @@ +import type { + TourSourceHints, + TTourSourceHintsReason, + TTourSourceHintsState, +} from '../entities/TourSourceHints.entity'; +import type { DB } from '../kysely/db'; +import { TrekRepository } from './_shared/trek-repository'; + +type HintsDB = Pick< + DB, + 'tour_source_hints' | 'tour_sources' | 'places' | 'trips' | 'trip_members' | 'wanderer_connections' +>; + +const PROVIDER = 'wanderer'; + +/** A snapshot the watch may look at, with what was last seen of its source. */ +export interface WatchedSourceRow { + place_id: number; + external_id: string; + /** The content as the import delivered it, `name.description.route`. */ + content_hash: string; + /** The trail's change time at the import. */ + external_updated: string | null; + /** The content and change time the user last saw in a manual check; null before one. */ + seen_hash: string | null; + seen_updated: string | null; + /** What the last look found; null when it found nothing to say or never looked. */ + state: TTourSourceHintsState | null; +} + +/** What a look found about one source, written for the user it belongs to. */ +interface TourSourceHintWrite { + place_id: number; + user_id: number; + state: TTourSourceHintsState | null; + /** The changed parts as a JSON array, null when none. */ + changes: string | null; + reason: TTourSourceHintsReason | null; + external_updated: string | null; + checked_at: string; +} + +export interface TourSourceHintRow { + place_id: number; + state: TTourSourceHintsState; + changes: string | null; + reason: TTourSourceHintsReason | null; + external_updated: string | null; + checked_at: string | null; +} + +export interface TourSourceHintListRow extends TourSourceHintRow { + trip_id: number; + trip_title: string; + tour_name: string; +} + +/** + * `tour_source_hints` (WAN-02): what the background watch over a user's own + * wanderer snapshots found, one row per snapshot, for the user who imported + * it. Nothing here reads or writes a tour; a hint goes with its snapshot and + * with its user. Only snapshots in trips the user can still open count, and a + * hint shows only while the user's connection points at the instance it came + * from, so a removed connection leaves the rows unread rather than deleted. + */ +export class TourSourceHintsRepository extends TrekRepository { + private db() { + return this.kysely(); + } + + /** + * The user's snapshots of one instance that are due for a look: never looked + * at, or last looked at before `checkedBefore`. Longest waiting first, at + * most `limit`, so every run stays within its budget and all get their turn. + */ + async listDue(userId: number, instance: string, checkedBefore: string, limit: number): Promise { + return await this.db() + .selectFrom('tour_sources as s') + .innerJoin('places as p', 'p.id', 's.place_id') + .leftJoin('tour_source_hints as h', 'h.place_id', 's.place_id') + .select([ + 's.place_id', + 's.external_id', + 's.content_hash', + 's.external_updated', + 'h.seen_hash', + 'h.seen_updated', + 'h.state', + ]) + .where('s.provider', '=', PROVIDER) + .where('s.instance', '=', instance) + .where('s.imported_by', '=', userId) + .where('p.trip_id', 'in', this.reachableTripIds(userId)) + .where((eb) => eb.or([eb('h.checked_at', 'is', null), eb('h.checked_at', '<', checkedBefore)])) + .orderBy((eb) => eb.fn.coalesce('h.checked_at', eb.val(''))) + .orderBy('s.place_id') + .limit(limit) + .execute() + .then((rows) => rows.map((row) => ({ ...row, state: (row.state as TTourSourceHintsState | null) ?? null }))); + } + + /** Writes what one look found; what the user last saw in a manual check stays as it is. */ + async record(rows: readonly TourSourceHintWrite[]): Promise { + if (rows.length === 0) return; + await this.db() + .insertInto('tour_source_hints') + .values([...rows]) + .onConflict((oc) => + oc.column('place_id').doUpdateSet((eb) => ({ + state: eb.ref('excluded.state'), + changes: eb.ref('excluded.changes'), + reason: eb.ref('excluded.reason'), + external_updated: eb.ref('excluded.external_updated'), + checked_at: eb.ref('excluded.checked_at'), + })), + ) + .execute(); + } + + /** + * A manual check read the source: its answer is what the user has seen now, + * so the open hint goes and later looks compare against this. Only for the + * user who imported the snapshot; anyone else's check leaves the row alone. + */ + async markSeen( + placeId: number, + userId: number, + seen: { contentHash: string; externalUpdated: string | null; checkedAt: string }, + ): Promise { + const source = await this.db() + .selectFrom('tour_sources') + .select('imported_by') + .where('place_id', '=', placeId) + .where('provider', '=', PROVIDER) + .executeTakeFirst(); + if (!source || source.imported_by !== userId) return; + await this.db() + .insertInto('tour_source_hints') + .values({ + place_id: placeId, + user_id: userId, + state: null, + changes: null, + reason: null, + external_updated: seen.externalUpdated, + seen_hash: seen.contentHash, + seen_updated: seen.externalUpdated, + checked_at: seen.checkedAt, + }) + .onConflict((oc) => + oc.column('place_id').doUpdateSet((eb) => ({ + state: null, + changes: null, + reason: null, + external_updated: eb.ref('excluded.external_updated'), + seen_hash: eb.ref('excluded.seen_hash'), + seen_updated: eb.ref('excluded.seen_updated'), + checked_at: eb.ref('excluded.checked_at'), + })), + ) + .execute(); + } + + /** The user's open hints, newest look first. */ + async listOpen(userId: number): Promise { + return await this.openHints(userId).orderBy('h.checked_at', 'desc').orderBy('h.place_id', 'desc').execute(); + } + + /** The user's open hint on one snapshot of a trip; undefined when there is none. */ + async findOpen(userId: number, tripId: number, placeId: number): Promise { + return await this.openHints(userId) + .where('p.trip_id', '=', tripId) + .where('h.place_id', '=', placeId) + .executeTakeFirst(); + } + + private openHints(userId: number) { + return this.db() + .selectFrom('tour_source_hints as h') + .innerJoin('tour_sources as s', 's.place_id', 'h.place_id') + .innerJoin('places as p', 'p.id', 'h.place_id') + .innerJoin('trips as t', 't.id', 'p.trip_id') + .innerJoin('wanderer_connections as c', 'c.user_id', 'h.user_id') + .select([ + 'h.place_id', + 'h.state', + 'h.changes', + 'h.reason', + 'h.external_updated', + 'h.checked_at', + 'p.trip_id', + 't.title as trip_title', + 'p.name as tour_name', + ]) + .where('h.user_id', '=', userId) + .where('h.state', 'is not', null) + .whereRef('c.url', '=', 's.instance') + .where('p.trip_id', 'in', this.reachableTripIds(userId)) + .$narrowType<{ state: TTourSourceHintsState; reason: TTourSourceHintsReason | null }>(); + } + + /** The trips the user can still open: their own and those they are a member of. */ + private reachableTripIds(userId: number) { + return this.db() + .selectFrom('trips') + .select('id') + .where('user_id', '=', userId) + .union(this.db().selectFrom('trip_members').select('trip_id as id').where('user_id', '=', userId)); + } +} diff --git a/server/src/db/repositories/WandererConnections.repository.ts b/server/src/db/repositories/WandererConnections.repository.ts index f452fed0e..4544c8789 100644 --- a/server/src/db/repositories/WandererConnections.repository.ts +++ b/server/src/db/repositories/WandererConnections.repository.ts @@ -18,6 +18,14 @@ export interface WandererConnectionRow { last_check_error: string | null; } +/** WAN-02: the background watch of one connection, as stored. */ +interface WandererWatchRow { + watch_enabled: number; + watch_last_run_at: string | null; + watch_failures: number; + watch_paused_until: string | null; +} + /** * `wanderer_connections`: one row per user who connected an instance, the * user id its primary key. Only the owner's own requests read or write it. @@ -42,8 +50,9 @@ export class WandererConnectionsRepository extends TrekRepository { const platform = this.platform(); const tokenColumns = data.token !== undefined ? { token: data.token } : {}; - const checkColumns = data.hostChanged ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {}; + // What the last check said was said about another server, or about another token. + const freshCredentials = data.hostChanged || (data.token !== undefined && data.token !== null); + const checkColumns = freshCredentials ? { last_checked_at: null, last_check_ok: null, last_check_error: null } : {}; await this.db() .insertInto('wanderer_connections') .values({ @@ -81,6 +92,54 @@ export class WandererConnectionsRepository extends TrekRepository { + return await this.db() + .selectFrom('wanderer_connections') + .select(['watch_enabled', 'watch_last_run_at', 'watch_failures', 'watch_paused_until']) + .where('user_id', '=', userId) + .executeTakeFirst(); + } + + /** Switches the watch on or off. Switching it on starts afresh, without the pause an earlier failure left. */ + async setWatch(userId: number, enabled: boolean): Promise { + await this.nativeUpdate( + { user: userId }, + enabled ? { watch_enabled: 1, watch_failures: 0, watch_paused_until: null } : { watch_enabled: 0 }, + ); + } + + /** + * The connections the watch may ask now: switched on, holding a token + * wanderer has not refused, and not waiting out a pause. The ones that + * waited longest come first, at most `limit` of them. + */ + async listWatchable(now: string, limit: number): Promise { + const rows = await this.db() + .selectFrom('wanderer_connections') + .select('user_id') + .where('watch_enabled', '=', 1) + .where('token', 'is not', null) + .where((eb) => eb.or([eb('last_check_error', 'is', null), eb('last_check_error', '!=', 'unauthorized')])) + .where((eb) => eb.or([eb('watch_paused_until', 'is', null), eb('watch_paused_until', '<=', now)])) + .orderBy((eb) => eb.fn.coalesce('watch_last_run_at', eb.val(''))) + .orderBy('user_id') + .limit(limit) + .execute(); + return rows.map((row) => row.user_id); + } + + /** How a run of the watch went: when it ended, how many failed in a row and until when to wait. */ + async recordWatchRun( + userId: number, + run: { lastRunAt: string; failures: number; pausedUntil: string | null }, + ): Promise { + await this.nativeUpdate( + { user: userId }, + { watch_last_run_at: run.lastRunAt, watch_failures: run.failures, watch_paused_until: run.pausedUntil }, + ); + } + async remove(userId: number): Promise { await this.nativeDelete({ user: userId }); } diff --git a/server/src/nest/notifications/notification-events.ts b/server/src/nest/notifications/notification-events.ts index bdaa0f1ab..cac2d32a5 100644 --- a/server/src/nest/notifications/notification-events.ts +++ b/server/src/nest/notifications/notification-events.ts @@ -42,6 +42,7 @@ export type NotifEventType = | 'replica_failure' | 'paddle_region_requested' | 'synology_session_cleared' + | 'wanderer_source_hint' | 'plugin_notification'; /** Every event, in the order the preferences UI lists them. */ @@ -60,6 +61,7 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [ 'replica_failure', 'paddle_region_requested', 'synology_session_cleared', + 'wanderer_source_hint', 'plugin_notification', ]; diff --git a/server/src/nest/notifications/notifications.service.ts b/server/src/nest/notifications/notifications.service.ts index 1e500310f..a700fa5e4 100644 --- a/server/src/nest/notifications/notifications.service.ts +++ b/server/src/nest/notifications/notifications.service.ts @@ -228,6 +228,20 @@ const EVENT_NOTIFICATION_CONFIG: Record = { navigateTextKey: 'notif.action.view_admin', navigateTarget: () => '/admin?tab=addons', }, + // WAN-02: the wanderer watch found something about a snapshot's source; the + // text says which of the three it is, the button opens the tour in its trip. + wanderer_source_hint: { + inAppType: 'navigate', + titleKey: 'notif.wanderer_source_hint.title', + textKey: (p) => + p.state === 'changed' + ? 'notif.wanderer_source_hint.changed' + : p.state === 'unverifiable' + ? 'notif.wanderer_source_hint.unverifiable' + : 'notif.wanderer_source_hint.possible_change', + navigateTextKey: 'notif.action.view_tour', + navigateTarget: (p) => (p.tripId && p.placeId ? `/trips/${p.tripId}?tab=plan&place=${p.placeId}` : null), + }, synology_session_cleared: { inAppType: 'simple', titleKey: 'notifications.synologySessionCleared.title', diff --git a/server/src/nest/notifications/transports/ntfy.service.ts b/server/src/nest/notifications/transports/ntfy.service.ts index 80dffe5b1..aa9e247a5 100644 --- a/server/src/nest/notifications/transports/ntfy.service.ts +++ b/server/src/nest/notifications/transports/ntfy.service.ts @@ -35,6 +35,7 @@ const NTFY_EVENT_META: Partial { const tid = tripRowId(tripId); const pid = toRowId(placeId); const row = pid === null ? undefined : await this.toursRepo.findProvenance(tid, pid); - if (!row) throw new DomainError(404, 'Tour not found'); + if (!row || pid === null) throw new DomainError(404, 'Tour not found'); if (row.origin !== 'wanderer') { throw new DomainError(400, 'This tour has no wanderer source', { code: 'wanderer_no_source', @@ -177,6 +181,7 @@ export class ToursWandererService { } const reimport = { trailId: externalId, contentHash: fetched.contentHash }; const externalUpdated = fetched.trail.updated ?? null; + await this.watch.noteSeen(pid, userId, { contentHash: fetched.contentHash, externalUpdated }); if (row.source_content_hash === null) return { ...answer('unknown'), externalUpdated, reimport }; const changes = changedParts(row.source_content_hash, fetched.contentHash); return changes.length === 0 diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts index 38f2f1c62..0d9db5dd5 100644 --- a/server/src/nest/tours/tours.helpers.ts +++ b/server/src/nest/tours/tours.helpers.ts @@ -70,6 +70,23 @@ export function changedParts(before: string, now: string): Array<(typeof CONTENT return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]); } +/** + * WAN-02: the parts of a content fingerprint a trail's record alone proves + * changed. The record carries the name and the description, never the line, + * so a changed route is never claimed here. + */ +export function recordChangedParts( + contentHash: string, + name: string, + description: string | null, +): Array<'name' | 'description'> { + const [nameHash, descriptionHash] = contentHash.split('.'); + return [ + ...(fingerprint(name.trim()) === nameHash ? [] : (['name'] as const)), + ...(fingerprint((description ?? '').trim()) === descriptionHash ? [] : (['description'] as const)), + ]; +} + /** * At most `most` points of a line, evenly spread along it with its ends kept: * a preview or a search gets the whole shape without the thousands of points. diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index 552ee75b1..0643cbcfb 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -5,6 +5,7 @@ import { TourActivityExclusions } from '../../db/entities/TourActivityExclusions import { TourChangeSteps } from '../../db/entities/TourChangeSteps.entity'; import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity'; import { TourPackingLinks } from '../../db/entities/TourPackingLinks.entity'; +import { TourSourceHints } from '../../db/entities/TourSourceHints.entity'; import { TourSources } from '../../db/entities/TourSources.entity'; import { TourStationVisits } from '../../db/entities/TourStationVisits.entity'; import { TourTypes } from '../../db/entities/TourTypes.entity'; @@ -15,6 +16,7 @@ import { AssignmentsDomainModule } from '../assignments/assignments-domain.modul import { CollectionsModule } from '../collections/collections.module'; import { MapsModule } from '../maps/maps.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { OverpassModule } from '../overpass/overpass.module'; import { PackingModule } from '../packing/packing.module'; import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module'; @@ -22,6 +24,7 @@ import { PermissionsModule } from '../permissions/permissions.module'; import { PlaceImportModule } from '../place-import/place-import.module'; import { PlacesModule } from '../places/places.module'; import { RoadtripModule } from '../roadtrip/roadtrip.module'; +import { SchedulingModule } from '../scheduling/scheduling.module'; import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { WandererModule } from '../wanderer/wanderer.module'; import { CityWalkBriefingService } from './city-walk-briefing.service'; @@ -71,6 +74,9 @@ import { ToursWandererService } from './tours-wanderer.service'; import { ToursController } from './tours.controller'; import { ToursMcp } from './tours.mcp'; import { ToursService } from './tours.service'; +import { WandererWatchController } from './wanderer-watch.controller'; +import { WandererWatchJob } from './wanderer-watch.job'; +import { WandererWatchService } from './wanderer-watch.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -97,7 +103,10 @@ import { Module } from '@nestjs/common'; * collection (LIB-03) are this domain's own rows; the collection's roles and * its live notice to its members come from CollectionsService. A tour points at * the packing list of its trip (P2D-XD-01) through a row of its own; the list - * and who sees which of its items stay PackingService's. + * and who sees which of its items stay PackingService's. The optional watch + * over a user's own wanderer snapshots (WAN-02) reads through WandererService, + * keeps its hints in this domain's own rows, tells the user through + * NotificationsService and runs hourly on SchedulingModule's registrar. */ @Module({ imports: [ @@ -125,12 +134,16 @@ import { Module } from '@nestjs/common'; CollectionsModule, // A tour's packing link is read against the items Packing lets the user see (P2D-XD-01). PackingModule, + // The wanderer watch tells the user of a new hint (WAN-02) and runs as a cron. + NotificationsModule, + SchedulingModule, MikroOrmModule.forFeature([ CollectionTours, Tours, TourTypes, TourWaypoints, TourSources, + TourSourceHints, TourObstacleReviews, TourPackingLinks, TourStationVisits, @@ -158,6 +171,7 @@ import { Module } from '@nestjs/common'; TourLibraryController, CollectionToursController, TourPackingLinkController, + WandererWatchController, ], providers: [ ToursService, @@ -183,6 +197,8 @@ import { Module } from '@nestjs/common'; TourActivityStatsService, CollectionToursService, TourPackingLinkService, + WandererWatchService, + WandererWatchJob, ToursMcp, TourLibraryMcp, TourWalkImportMcp, diff --git a/server/src/nest/tours/wanderer-watch.controller.ts b/server/src/nest/tours/wanderer-watch.controller.ts new file mode 100644 index 000000000..6130e9128 --- /dev/null +++ b/server/src/nest/tours/wanderer-watch.controller.ts @@ -0,0 +1,40 @@ +import { ADDON_IDS } from '../../addons'; +import type { User } from '../../types'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { getClientIp } from '../audit/client-ip'; +import { CurrentUser } from '../auth-core/current-user.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { WandererEnabledGuard } from '../wanderer/wanderer-enabled.guard'; +import { WandererWatchDto } from './dto/wanderer-watch.dto'; +import { WandererWatchService } from './wanderer-watch.service'; +import { Body, Controller, Get, Put, Req, UseGuards } from '@nestjs/common'; +import { wandererWatchSchema } from '@trek/shared'; + +import type { Request } from 'express'; + +/** + * `/api/integrations/wanderer/watch` (WAN-02): the optional background watch + * over the caller's own wanderer snapshots, beside the connection it runs on. + * It reads and switches only the caller's own watch, and lists only the + * caller's own hints. + */ +@Controller('api/integrations/wanderer/watch') +@UseGuards(AddonGuard, JwtAuthGuard, WandererEnabledGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class WandererWatchController { + constructor(private readonly watch: WandererWatchService) {} + + @Get() + @ResponseContract(wandererWatchSchema) + status(@CurrentUser() user: User) { + return this.watch.status(user.id); + } + + @Put() + @ResponseContract(wandererWatchSchema) + setEnabled(@CurrentUser() user: User, @Body() body: WandererWatchDto, @Req() req: Request) { + return this.watch.setEnabled(user.id, body.enabled, getClientIp(req)); + } +} diff --git a/server/src/nest/tours/wanderer-watch.helpers.ts b/server/src/nest/tours/wanderer-watch.helpers.ts new file mode 100644 index 000000000..48cc234c9 --- /dev/null +++ b/server/src/nest/tours/wanderer-watch.helpers.ts @@ -0,0 +1,74 @@ +import type { WatchedSourceRow } from '../../db/repositories/TourSourceHints.repository'; +import type { WandererTrailLook } from '../wanderer/wanderer.service'; +import { recordChangedParts } from './tours.helpers'; +import type { TourSourceHintChange, TourSourceHintReason, TourSourceHintState } from '@trek/shared'; + +/** + * WAN-02: the pure half of the background watch over a user's own wanderer + * snapshots: its budget and what a look at a trail's record says. + * + * The budget keeps a home instance unbothered. A run asks at most + * {@link WATCH_CONNECTIONS_PER_RUN} connections, each for at most + * {@link WATCH_SOURCES_PER_RUN} trail records one after another (plus the two + * small reads that name the account's own authors), and a source is looked at + * again no sooner than {@link WATCH_RECHECK_MS} later. A failing instance is + * left alone for longer after each failure in a row, up to + * {@link WATCH_BACKOFF_MAX_MS}; a rate limit or an outage is such a failure. + * Every read keeps the client's timeout and size cap. + */ +export const WATCH_CONNECTIONS_PER_RUN = 25; +export const WATCH_SOURCES_PER_RUN = 10; +export const WATCH_RECHECK_MS = 24 * 60 * 60 * 1000; +export const WATCH_BACKOFF_BASE_MS = 60 * 60 * 1000; +export const WATCH_BACKOFF_MAX_MS = 48 * 60 * 60 * 1000; + +/** What a look found; a null state says nothing, least of all that nothing changed. */ +export interface WatchVerdict { + state: TourSourceHintState | null; + changes: TourSourceHintChange[]; + reason: TourSourceHintReason | null; +} + +const NOTHING_TO_SAY: WatchVerdict = { state: null, changes: [], reason: null }; + +function unverifiable(reason: TourSourceHintReason): WatchVerdict { + return { state: 'unverifiable', changes: [], reason }; +} + +/** Two change times name the same moment, however the instance spelled them. */ +function sameInstant(a: string, b: string): boolean { + if (a === b) return true; + const left = Date.parse(a); + return !Number.isNaN(left) && left === Date.parse(b); +} + +/** + * What one look at a trail's record says about a snapshot. It compares with + * what the user last saw in a manual check, else with the import: + * + * - a name or description that differs is proven by the record: `changed`; + * - a change time that moved is only a suspicion: `possible_change`; + * - a trail that is gone or no longer the user's own, or a change time + * missing on either side, cannot be judged: `unverifiable`; + * - anything else says nothing. It never says "unchanged": the line may still + * differ, and only the manual check reads it. + */ +export function verdictOf(source: WatchedSourceRow, look: WandererTrailLook | undefined): WatchVerdict { + if (!look || look.kind === 'gone') return unverifiable('unavailable'); + const reviewed = source.seen_hash !== null; + const changes = recordChangedParts(source.seen_hash ?? source.content_hash, look.name, look.description); + if (changes.length > 0) return { state: 'changed', changes, reason: null }; + const baseline = reviewed ? source.seen_updated : source.external_updated; + if (baseline === null || look.updated === null) return unverifiable('no_change_time'); + return sameInstant(baseline, look.updated) ? NOTHING_TO_SAY : { state: 'possible_change', changes: [], reason: null }; +} + +/** A hint the user has not been told about yet: something to say, and not what the last look said. */ +export function isNewHint(source: WatchedSourceRow, verdict: WatchVerdict): boolean { + return verdict.state !== null && verdict.state !== source.state; +} + +/** How long to leave an instance alone after this many failed runs in a row. */ +export function backoffMs(failures: number): number { + return Math.min(WATCH_BACKOFF_BASE_MS * 2 ** Math.max(failures - 1, 0), WATCH_BACKOFF_MAX_MS); +} diff --git a/server/src/nest/tours/wanderer-watch.job.ts b/server/src/nest/tours/wanderer-watch.job.ts new file mode 100644 index 000000000..7cc512c01 --- /dev/null +++ b/server/src/nest/tours/wanderer-watch.job.ts @@ -0,0 +1,32 @@ +import { logError } from '../audit/audit-log.logger'; +import { CronRegistrarService } from '../scheduling/cron-registrar.service'; +import { WandererWatchService } from './wanderer-watch.service'; +import { Injectable, type OnApplicationBootstrap } from '@nestjs/common'; + +/** + * WAN-02: the hourly run of the optional wanderer watch. Hourly is how often + * a connection may get its turn, not how often a trail is read: a source is + * looked at again only a day later, and a run with nothing due asks no + * instance anything. Whether wanderer is on, and which users switched the + * watch on, is read per run, so a change lands without a restart. + */ +@Injectable() +export class WandererWatchJob implements OnApplicationBootstrap { + constructor( + private readonly watch: WandererWatchService, + private readonly registrar: CronRegistrarService, + ) {} + + onApplicationBootstrap(): void { + if (!this.registrar.isEnabled()) return; + this.registrar.register('wanderer-watch', '23 * * * *', () => this.tick()); + } + + async tick(): Promise { + try { + await this.watch.run(); + } catch (err: unknown) { + logError(`wanderer watch: ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/server/src/nest/tours/wanderer-watch.service.ts b/server/src/nest/tours/wanderer-watch.service.ts new file mode 100644 index 000000000..f108950a0 --- /dev/null +++ b/server/src/nest/tours/wanderer-watch.service.ts @@ -0,0 +1,219 @@ +import { TourSourceHints } from '../../db/entities/TourSourceHints.entity'; +import { TOUR_SOURCE_HINT_CHANGES } from '../../db/json-columns'; +import type { + TourSourceHintListRow, + TourSourceHintRow, + TourSourceHintsRepository, + WatchedSourceRow, +} from '../../db/repositories/TourSourceHints.repository'; +import { dbNow, utcSuffix } from '../../db/types'; +import { decodeJson, encodeJson } from '../../utils/json-column'; +import { AddonsService } from '../addons/addons.service'; +import { logError } from '../audit/audit-log.logger'; +import { toRowId } from '../common/row-id'; +import { UnitOfWork } from '../database/unit-of-work'; +import { NotificationsService } from '../notifications/notifications.service'; +import { WandererService, type WandererTrailLook, type WandererWatchLooks } from '../wanderer/wanderer.service'; +import { + backoffMs, + isNewHint, + verdictOf, + WATCH_CONNECTIONS_PER_RUN, + WATCH_RECHECK_MS, + WATCH_SOURCES_PER_RUN, + type WatchVerdict, +} from './wanderer-watch.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable } from '@nestjs/common'; +import type { TourSourceHint, TourSourceHintResponse, WandererSourceHintItem, WandererWatch } from '@trek/shared'; + +/** A hint as it goes out: the stored changes read back, the look's time in ISO. */ +function hintOf(row: TourSourceHintRow): TourSourceHint { + return { + state: row.state, + changes: decodeJson(TOUR_SOURCE_HINT_CHANGES, row.changes, `hint ${row.place_id}`), + reason: row.reason, + externalUpdated: row.external_updated, + checkedAt: utcSuffix(row.checked_at) ?? '', + }; +} + +function itemOf(row: TourSourceHintListRow): WandererSourceHintItem { + return { + ...hintOf(row), + placeId: row.place_id, + tripId: row.trip_id, + tripTitle: row.trip_title, + tourName: row.tour_name, + }; +} + +/** A look that had nothing due: no trail asked, nothing read. */ +const NOTHING_READ: WandererWatchLooks = { ok: true, looks: new Map() }; + +interface Finding { + source: WatchedSourceRow; + verdict: WatchVerdict; +} + +/** + * WAN-02: the optional background watch over a user's own wanderer snapshots. + * + * Off until the user switches it on for their connection. A run looks, within + * the budget of `wanderer-watch.helpers.ts`, at the small records of the + * trails the user imported into trips they can still open, and writes per + * snapshot what the record suggests. New hints reach the user as a + * notification that leads to the tour, where the manual check (MF-03) reads + * the source in full and a confirmed reimport takes a change in. + * + * It never writes a tour, never downloads a GPX and never asks through anyone + * else's connection. A removed connection, the switch off, a refused token or + * wanderer switched off by the admin end the looks; the hints and snapshots + * already there stay. + */ +@Injectable() +export class WandererWatchService { + /** A run still going when the next is due is not started twice in this process. */ + private running = false; + + constructor( + private readonly addons: AddonsService, + private readonly wanderer: WandererService, + private readonly notifications: NotificationsService, + private readonly uow: UnitOfWork, + @InjectRepository(TourSourceHints) private readonly hints: TourSourceHintsRepository, + ) {} + + /** The cron's entry: every connection that may be asked now, one after another. */ + async run(now: Date = new Date()): Promise { + if (this.running || !(await this.addons.isWandererAvailable())) return; + this.running = true; + try { + const userIds = await this.wanderer.watchableUserIds(dbNow(now), WATCH_CONNECTIONS_PER_RUN); + await userIds.reduce(async (previous, userId) => { + await previous; + // One instance that fails must not keep the others from their turn. + await this.watchUser(userId, now).catch((err: unknown) => { + logError(`wanderer watch failed for user ${userId}: ${err instanceof Error ? err.message : String(err)}`); + }); + }, Promise.resolve()); + } finally { + this.running = false; + } + } + + /** + * One user's due snapshots. An instance that fails is left alone for longer + * after each failure in a row; the hints already there stay as they are. + */ + private async watchUser(userId: number, now: Date): Promise { + const instance = await this.wanderer.instanceOf(userId); + const checkedBefore = dbNow(new Date(now.getTime() - WATCH_RECHECK_MS)); + const due = await this.hints.listDue(userId, instance, checkedBefore, WATCH_SOURCES_PER_RUN); + // Nothing due asks the instance nothing. + const ids = due.map((source) => source.external_id); + const read = ids.length > 0 ? await this.wanderer.lookAtTrails(userId, ids) : NOTHING_READ; + if (!read.ok) { + const failures = (await this.wanderer.watchState(userId)).failures + 1; + await this.wanderer.noteWatchRun(userId, { + lastRunAt: dbNow(now), + failures, + pausedUntil: dbNow(new Date(now.getTime() + backoffMs(failures))), + }); + return; + } + const findings = due.map((source) => ({ source, verdict: verdictOf(source, read.looks.get(source.external_id)) })); + await this.uow.transactional(async () => { + await this.hints.record( + findings.map(({ source, verdict }) => ({ + place_id: source.place_id, + user_id: userId, + state: verdict.state, + changes: verdict.changes.length > 0 ? encodeJson(TOUR_SOURCE_HINT_CHANGES, verdict.changes) : null, + reason: verdict.reason, + external_updated: lookedUpdated(read.looks.get(source.external_id)), + checked_at: dbNow(now), + })), + ); + await this.wanderer.noteWatchRun(userId, { lastRunAt: dbNow(now), failures: 0, pausedUntil: null }); + }); + await this.announce(userId, findings.filter(({ source, verdict }) => isNewHint(source, verdict))); + } + + /** + * Tell the user about the hints they have not heard of, one notification per + * tour so each leads to its own. + * @txStandalone the notices stand on their own; a failed one never undoes what the look wrote + */ + private async announce(userId: number, fresh: readonly Finding[]): Promise { + if (fresh.length === 0) return; + const rows = new Map((await this.hints.listOpen(userId)).map((row) => [row.place_id, row])); + await fresh.reduce(async (previous, { source, verdict }) => { + await previous; + const row = rows.get(source.place_id); + if (!row || !verdict.state) return; + await this.notifications + .send({ + event: 'wanderer_source_hint', + actorId: null, + scope: 'user', + targetId: userId, + params: { + state: verdict.state, + tour: row.tour_name, + trip: row.trip_title, + tripId: String(row.trip_id), + placeId: String(row.place_id), + }, + }) + .catch((err: unknown) => { + logError(`wanderer watch notice for ${source.place_id}: ${err instanceof Error ? err.message : String(err)}`); + }); + }, Promise.resolve()); + } + + /** The watch of the user's connection, with the open hints. */ + async status(userId: number): Promise { + const state = await this.wanderer.watchState(userId); + return { + enabled: state.enabled, + connected: state.connected, + lastRunAt: utcSuffix(state.lastRunAt), + pausedUntil: utcSuffix(state.pausedUntil), + blocked: state.blocked, + hints: state.connected ? (await this.hints.listOpen(userId)).map(itemOf) : [], + }; + } + + /** Switch the watch on or off; switching it off ends further looks at once. */ + async setEnabled(userId: number, enabled: boolean, clientIp: string | null): Promise { + await this.wanderer.setWatch(userId, enabled, clientIp); + return await this.status(userId); + } + + /** The caller's own open hint on one snapshot of the trip; null when there is none. */ + async hintFor(tripId: string, userId: number, placeId: string): Promise { + const tid = toRowId(tripId); + const pid = toRowId(placeId); + const row = tid === null || pid === null ? undefined : await this.hints.findOpen(userId, tid, pid); + return { hint: row ? hintOf(row) : null }; + } + + /** + * A manual check read the source in full: what it found is what the user has + * seen now, so their open hint goes and later looks compare with this. + * @txStandalone the note of what the user saw stands on its own; the check around it writes nothing else + */ + async noteSeen( + placeId: number, + userId: number, + seen: { contentHash: string; externalUpdated: string | null }, + ): Promise { + await this.hints.markSeen(placeId, userId, { ...seen, checkedAt: dbNow() }); + } +} + +/** The change time a look read, kept for the hint to show; none when the trail could not be read. */ +function lookedUpdated(look: WandererTrailLook | undefined): string | null { + return look?.kind === 'record' ? look.updated : null; +} diff --git a/server/src/nest/wanderer/wanderer.service.ts b/server/src/nest/wanderer/wanderer.service.ts index 1e084776f..4ad9bebf8 100644 --- a/server/src/nest/wanderer/wanderer.service.ts +++ b/server/src/nest/wanderer/wanderer.service.ts @@ -43,6 +43,34 @@ export interface WandererTrailDownload { gpx: string; } +/** + * WAN-02: a trail as the background watch sees it, from its small record + * alone: its texts and its change time, or `gone` when it was deleted, is + * refused or is no longer the user's own. + */ +export type WandererTrailLook = + | { kind: 'record'; name: string; description: string | null; updated: string | null } + | { kind: 'gone' }; + +/** + * WAN-02: what one look of the watch read: a look per trail, or why the + * instance itself could not be asked, for the watch to back off from. + */ +export type WandererWatchLooks = + | { ok: true; looks: Map } + | { ok: false; code: WandererErrorCode }; + +/** WAN-02: the background watch of one user's connection. */ +interface WandererWatchState { + connected: boolean; + enabled: boolean; + lastRunAt: string | null; + failures: number; + pausedUntil: string | null; + /** wanderer refused the token; the watch asks nothing until a test passes or a new token is saved. */ + blocked: boolean; +} + /** An upstream failure as the API answers it: never a 401, which the app reads as its own session ending. */ function refusalOf(err: WandererError): DomainError { const status = err.code === 'not_found' ? 404 : err.code === 'forbidden' || err.code === 'not_own_trail' ? 403 : 502; @@ -145,12 +173,7 @@ export class WandererService { private async credentials(userId: number): Promise { const row = await this.connections.findRow(userId); const token = row?.token ? decrypt_api_key(row.token) : null; - if (!row?.url || !token) { - throw new DomainError(409, 'wanderer is not connected', { - code: 'wanderer_not_connected', - details: { code: 'not_connected' }, - }); - } + if (!row?.url || !token) throw notConnected(); return { baseUrl: row.url, token, allowInsecureTls: !!row.allow_insecure_tls }; } @@ -216,6 +239,95 @@ export class WandererService { } } + /** WAN-02: whether the user's connection is watched in the background, and how its last run went. */ + async watchState(userId: number): Promise { + const row = await this.connections.findRow(userId); + const watch = await this.connections.findWatch(userId); + return { + connected: !!(row?.url && row.token), + enabled: watch?.watch_enabled === 1, + lastRunAt: watch?.watch_last_run_at ?? null, + failures: watch?.watch_failures ?? 0, + pausedUntil: watch?.watch_paused_until ?? null, + blocked: row?.last_check_ok === 0 && row.last_check_error === 'unauthorized', + }; + } + + /** + * WAN-02: switch the background watch on or off. It only ever acts on the + * user's own connection, so there has to be one to switch it on for. + */ + async setWatch(userId: number, enabled: boolean, clientIp: string | null): Promise { + if (enabled && !(await this.watchState(userId)).connected) throw notConnected(); + await this.connections.setWatch(userId, enabled); + await this.audit.writeAudit({ + userId, + action: enabled ? 'wanderer.watch_enabled' : 'wanderer.watch_disabled', + ip: clientIp, + details: {}, + }); + } + + /** WAN-02: the users whose connection the watch may ask now, longest waiting first. */ + async watchableUserIds(now: string, limit: number): Promise { + return await this.connections.listWatchable(now, limit); + } + + /** + * WAN-02: note how a run of the watch went on the connection. + * @txStandalone the run's bookkeeping stands on its own: it is true whatever the hints written beside it say + */ + async noteWatchRun( + userId: number, + run: { lastRunAt: string; failures: number; pausedUntil: string | null }, + ): Promise { + await this.connections.recordWatchRun(userId, run); + } + + /** + * WAN-02: the records of the given trails, for the background watch. One + * small read per trail, one after another, and never its GPX: the change + * time and the texts are what a look can afford. A trail that is gone, + * refused or someone else's reads as `gone`; a failure of the instance + * itself (unreachable, an error, a refused token, which is noted on the + * connection) ends the whole look with its reason. + */ + async lookAtTrails(userId: number, trailIds: readonly string[]): Promise { + const creds = await this.credentials(userId); + const looks = new Map(); + try { + const authors = await this.client.ownActors(creds); + await [...new Set(trailIds)].reduce(async (previous, id) => { + await previous; + looks.set(id, await this.lookAt(creds, id, authors)); + }, Promise.resolve()); + } catch (err: unknown) { + if (!(err instanceof WandererError)) throw err; + if (err.code === 'unauthorized') await this.noteCheck(userId, false, err.code); + return { ok: false, code: err.code }; + } + return { ok: true, looks }; + } + + private async lookAt(creds: WandererCreds, id: string, authors: readonly string[]): Promise { + try { + const trail = await this.client.trail(creds, id); + if (!trail.author || !authors.includes(trail.author)) return { kind: 'gone' }; + return { + kind: 'record', + name: trail.name, + description: trail.description ?? null, + updated: trail.updated ?? null, + }; + } catch (err: unknown) { + // Deleted or refused: the trail is out of reach, not the instance. + if (err instanceof WandererError && (err.code === 'not_found' || err.code === 'forbidden')) { + return { kind: 'gone' }; + } + throw err; + } + } + /** The list row of a trail by its id, as the search would show it. */ async findTrail(userId: number, trailId: string): Promise { const creds = await this.credentials(userId); @@ -229,6 +341,13 @@ export class WandererService { } } +function notConnected(): DomainError { + return new DomainError(409, 'wanderer is not connected', { + code: 'wanderer_not_connected', + details: { code: 'not_connected' }, + }); +} + function summaryOf(instance: string, hit: WandererSearchHit): WandererTrailFound { const author = hit.author_name ? (hit.domain ? `${hit.author_name}@${hit.domain}` : hit.author_name) : null; return { diff --git a/server/tests/e2e/wanderer.e2e.test.ts b/server/tests/e2e/wanderer.e2e.test.ts index 652893bd2..083790c6d 100644 --- a/server/tests/e2e/wanderer.e2e.test.ts +++ b/server/tests/e2e/wanderer.e2e.test.ts @@ -1,5 +1,6 @@ /** - * wanderer e2e: the personal connection (/api/integrations/wanderer) and the + * wanderer e2e: the personal connection (/api/integrations/wanderer), its + * optional background watch (/api/integrations/wanderer/watch, WAN-02) and the * trip-side search, preview and import (/api/trips/:tripId/tours/wanderer) * through the real JwtAuthGuard, AddonGuard, WandererEnabledGuard and * TripAccessGuard, over the real services and repositories on a migrated temp @@ -16,11 +17,13 @@ import { Trips } from '../../src/db/entities/Trips.entity'; import { WandererConnections } from '../../src/db/entities/WandererConnections.entity'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; +import { withRequestContext } from '../../src/nest/database/request-context'; import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; import { ToursModule } from '../../src/nest/tours/tours.module'; +import { WandererWatchService } from '../../src/nest/tours/wanderer-watch.service'; import { WandererClient, WandererError, type WandererSearchHit } from '../../src/nest/wanderer/wanderer.client'; import { WandererModule } from '../../src/nest/wanderer/wanderer.module'; import { createSnapshotTestDb } from '../helpers/db-mock'; @@ -32,6 +35,7 @@ import { FakeRealtimeService } from '../helpers/fake-realtime'; import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; import { sessionCookie } from './harness'; +import { MikroORM } from '@mikro-orm/core'; import { Test } from '@nestjs/testing'; import cookieParser from 'cookie-parser'; @@ -533,4 +537,72 @@ describe('wanderer e2e', () => { expect(detail.body.provenance.source.importedAt).toEqual(expect.any(String)); }); }); + + describe('the background watch over the caller own snapshots (WAN-02)', () => { + const watchRoute = (userId = OWNER) => request(server).get('/api/integrations/wanderer/watch').set(as(userId)); + const switchWatch = (enabled: unknown, userId = OWNER) => + request(server).put('/api/integrations/wanderer/watch').set(as(userId)).send({ enabled }); + const hint = (placeId: number, userId = OWNER) => + request(server).get(`/api/trips/5/tours/wanderer/sources/${placeId}/hint`).set(as(userId)); + const imported = async () => { + await connect(); + const res = await importTrail((await preview()).body.contentHash); + return res.body.tour.place_id as number; + }; + + it('is off until switched on, and only for a connection that exists', async () => { + expect((await watchRoute()).body).toEqual({ + enabled: false, + connected: false, + lastRunAt: null, + pausedUntil: null, + blocked: false, + hints: [], + }); + const refused = await switchWatch(true); + expect(refused.status).toBe(409); + expect(refused.body).toMatchObject({ code: 'not_connected' }); + expect((await switchWatch('yes')).status).toBe(400); + + await connect(); + expect((await switchWatch(true)).body).toMatchObject({ enabled: true, connected: true }); + expect((await switchWatch(false)).body).toMatchObject({ enabled: false }); + }); + + it('a moved trail leaves a hint for its importer, the manual check clears it, and no tour changes', async () => { + const placeId = await imported(); + await switchWatch(true); + client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' }); + const before = await countRows(orm, Places); + const gpxReads = client.gpx.mock.calls.length; + // The cron's tick, in the request context the registrar gives every tick. + await withRequestContext(app.get(MikroORM), () => app.get(WandererWatchService).run()); + expect(client.gpx).toHaveBeenCalledTimes(gpxReads); + expect(await countRows(orm, Places)).toBe(before); + + expect((await watchRoute()).body.hints).toEqual([ + expect.objectContaining({ placeId, tripId: 5, tripTitle: 'Alps', state: 'possible_change' }), + ]); + expect((await hint(placeId)).body).toEqual({ hint: expect.objectContaining({ state: 'possible_change' }) }); + + const checked = await request(server).post(`/api/trips/5/tours/wanderer/sources/${placeId}/check`).set(as(OWNER)); + expect(checked.body).toMatchObject({ state: 'unchanged' }); + expect((await hint(placeId)).body).toEqual({ hint: null }); + expect((await watchRoute()).body.hints).toEqual([]); + }); + + it('the hint of a tour is the caller own and asks the right of the check', async () => { + const placeId = await imported(); + expect((await hint(placeId, VIEWER)).status).toBe(403); + expect((await hint(placeId, OUTSIDER)).status).toBe(404); + checkPermission.mockReturnValue(true); + expect((await hint(placeId, VIEWER)).body).toEqual({ hint: null }); + }); + + it('404 while the admin has not allowed wanderer under Tours', async () => { + await setAppSetting(orm, 'tours_wanderer_enabled', 'false'); + expect((await watchRoute()).status).toBe(404); + expect((await switchWatch(true)).status).toBe(404); + }); + }); }); diff --git a/server/tests/helpers/factories/tours.ts b/server/tests/helpers/factories/tours.ts index aab36da86..30ce3c5d5 100644 --- a/server/tests/helpers/factories/tours.ts +++ b/server/tests/helpers/factories/tours.ts @@ -1,5 +1,6 @@ import { TourActivities } from '../../../src/db/entities/TourActivities.entity'; import { TourPackingLinks } from '../../../src/db/entities/TourPackingLinks.entity'; +import { TourSources } from '../../../src/db/entities/TourSources.entity'; import { TourStationVisits, type TTourStationVisitsStatus } from '../../../src/db/entities/TourStationVisits.entity'; import { TourWaypoints, type TTourWaypointsImportance } from '../../../src/db/entities/TourWaypoints.entity'; import { Tours } from '../../../src/db/entities/Tours.entity'; @@ -97,3 +98,29 @@ export async function linkTourPacking( ): Promise { await insertRow(orm, TourPackingLinks, { place: placeId, category, linkedByRef: linkedBy }); } + +/** + * RS-05: the import record of a tour copied in from wanderer, as the import + * writes it; the content and local fingerprints default to placeholders. + */ +export async function makeTourSource( + orm: FactoryOrm, + placeId: number, + importedBy: number | null, + overrides: EntityData = {}, +): Promise { + await insertRow( + orm, + TourSources, + { + place: placeId, + provider: 'wanderer', + instance: 'https://wanderer.example.org', + external_id: 'trail1', + content_hash: 'n.d.r', + local_hash: 'n.r', + importedByRef: importedBy, + }, + overrides, + ); +} diff --git a/server/tests/unit/db/entities-index.test.ts b/server/tests/unit/db/entities-index.test.ts index e091c76ce..7a12cdf9d 100644 --- a/server/tests/unit/db/entities-index.test.ts +++ b/server/tests/unit/db/entities-index.test.ts @@ -45,14 +45,15 @@ describe('ALL_ENTITIES', () => { * + tour_activities, the recorded tracks assigned to a tour * + tour_activity_exclusions, the tours a user left out of their own activity sums * + collection_tours, the whole tours a collection keeps - * + tour_packing_links, the packing list or category a tour points at) + * + tour_packing_links, the packing list or category a tour points at + * + tour_source_hints, what the wanderer watch found about a snapshot's source) * is registered, and no two share a class name (a silent duplicate would * shadow one of them in `orm.getMetadata()`, exactly the kind of drift * `entity-schema-parity.test.ts` can't see — it iterates the metadata map, * which de-duplicates by class name). */ - it('ENT-003: exactly 148 entities, every class name unique', () => { - expect(ALL_ENTITIES.length).toBe(148); + it('ENT-003: exactly 149 entities, every class name unique', () => { + expect(ALL_ENTITIES.length).toBe(149); const names = ALL_ENTITIES.map((s) => s.meta.className); expect(new Set(names).size).toBe(names.length); }); diff --git a/server/tests/unit/db/entity-class-fields.test.ts b/server/tests/unit/db/entity-class-fields.test.ts index 6ffdb790b..83fb1f97b 100644 --- a/server/tests/unit/db/entity-class-fields.test.ts +++ b/server/tests/unit/db/entity-class-fields.test.ts @@ -42,7 +42,8 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; // + tour_activity_exclusions: id, user_id, place_id (an exclusion always names its user and its tour). // + collection_tours: id, collection_id, name, waypoints, tour_type, origin, source_trip_title, source_tour_name // (a kept tour is only ever written whole, from the tour it was saved from). -const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 518; +// + tour_source_hints: user_id (a hint is only ever written for the user whose snapshot it is about). +const KNOWN_UNINITIALISED_NOT_NULL_SCALARS = 519; describe('entity class fields never carry a NaN initialiser (task-4-review-shape.md, Important 1)', () => { it('CLASSFIELD-001: new X() has no own property whose value is NaN, across all 133 entities', () => { diff --git a/server/tests/unit/db/legacy-baseline.test.ts b/server/tests/unit/db/legacy-baseline.test.ts index 256556d32..cced189b1 100644 --- a/server/tests/unit/db/legacy-baseline.test.ts +++ b/server/tests/unit/db/legacy-baseline.test.ts @@ -78,6 +78,7 @@ const UNNUMBERED = [ 'Migration20200101046010_a_day_can_hold_one_stage_of_a_tour', 'Migration20200101046100_a_collection_keeps_whole_tours', 'Migration20200101046110_a_tour_points_at_its_packing_list', + 'Migration20200101046120_a_wanderer_connection_can_watch_its_sources', ]; // The last step the positional runner ever had; planLegacyBaseline refuses a // schema_version past it. diff --git a/server/tests/unit/db/repositories/TourSourceHints.repository.test.ts b/server/tests/unit/db/repositories/TourSourceHints.repository.test.ts new file mode 100644 index 000000000..3b5db7bb3 --- /dev/null +++ b/server/tests/unit/db/repositories/TourSourceHints.repository.test.ts @@ -0,0 +1,197 @@ +/** + * `TourSourceHintsRepository` (WAN-02): what the background watch over a + * user's own wanderer snapshots found, and which snapshots it may look at. + * Seeded through the ORM factories over the migrated snapshot schema. + */ +import { TourSourceHints } from '../../../../src/db/entities/TourSourceHints.entity'; +import { TripMembers } from '../../../../src/db/entities/TripMembers.entity'; +import { WandererConnections } from '../../../../src/db/entities/WandererConnections.entity'; +import type { TourSourceHintsRepository } from '../../../../src/db/repositories/TourSourceHints.repository'; +import { createSnapshotTestDb } from '../../../helpers/db-mock'; +import { makePlace } from '../../../helpers/factories/places'; +import { deleteRows, findRow, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { makeTour, makeTourSource } from '../../../helpers/factories/tours'; +import { addTripMember, makeTrip } from '../../../helpers/factories/trips'; +import { makeUser } from '../../../helpers/factories/users'; +import { resetTestDb } from '../../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../../helpers/test-orm'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const INSTANCE = 'https://wanderer.example.org'; +const testDb = createSnapshotTestDb(); +let t: TestOrm; +let repo: TourSourceHintsRepository; + +beforeAll(async () => { + t = await createTestOrm(testDb); + repo = t.repo(TourSourceHints); +}); +beforeEach(() => { + resetTestDb(testDb); + t.clear(); +}); +afterAll(async () => { + await t.close(); + testDb.close(); +}); + +/** A wanderer snapshot in the trip, imported by `importedBy`. */ +async function snapshot(tripId: number, importedBy: number | null, externalId: string, instance = INSTANCE) { + const place = await makePlace(t, tripId, { name: `Trail ${externalId}` }); + await makeTour(t, place.id, { origin: 'wanderer' }); + await makeTourSource(t, place.id, importedBy, { external_id: externalId, instance, external_updated: '2026-10-01' }); + return place.id; +} + +async function connect(userId: number, url = INSTANCE) { + await insertRow(t, WandererConnections, { user: userId, url, token: 'enc' }); +} + +const look = (placeId: number, userId: number, checkedAt: string, state: 'possible_change' | null = null) => ({ + place_id: placeId, + user_id: userId, + state, + changes: null, + reason: null, + external_updated: '2026-10-02', + checked_at: checkedAt, +}); + +describe('TourSourceHintsRepository', () => { + it('THREPO-001: lists only the user own imports of the instance, in trips they can still open', async () => { + const { user } = await makeUser(t); + const { user: other } = await makeUser(t); + const own = await makeTrip(t, user.id, { title: 'Alps' }); + const shared = await makeTrip(t, other.id, { title: 'Shared' }); + await addTripMember(t, shared.id, user.id); + const left = await makeTrip(t, other.id, { title: 'Left' }); + + const mine = await snapshot(own.id, user.id, 'a'); + const inShared = await snapshot(shared.id, user.id, 'b'); + await snapshot(own.id, other.id, 'c'); + await snapshot(own.id, user.id, 'd', 'https://elsewhere.example'); + await snapshot(left.id, user.id, 'e'); + + const due = await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10); + expect(due.map((row) => row.place_id).sort()).toEqual([mine, inShared].sort()); + expect(due[0]).toMatchObject({ + content_hash: 'n.d.r', + external_updated: '2026-10-01', + seen_hash: null, + state: null, + }); + }); + + it('THREPO-002: never looked at first, then the longest waiting, within the cutoff and the limit', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id); + const fresh = await snapshot(trip.id, user.id, 'a'); + const old = await snapshot(trip.id, user.id, 'b'); + const older = await snapshot(trip.id, user.id, 'c'); + const recent = await snapshot(trip.id, user.id, 'd'); + await repo.record([ + look(old, user.id, '2026-10-18 10:00:00'), + look(older, user.id, '2026-10-17 10:00:00'), + look(recent, user.id, '2026-10-19 12:00:00'), + ]); + + const due = await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 10); + expect(due.map((row) => row.place_id)).toEqual([fresh, older, old]); + expect((await repo.listDue(user.id, INSTANCE, '2026-10-19 00:00:00', 2)).map((row) => row.place_id)).toEqual([ + fresh, + older, + ]); + }); + + it('THREPO-003: a look writes its finding and keeps what the user last saw', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id); + const placeId = await snapshot(trip.id, user.id, 'a'); + await repo.markSeen(placeId, user.id, { + contentHash: 'seen.hash.now', + externalUpdated: '2026-10-05', + checkedAt: '2026-10-05 08:00:00', + }); + await repo.record([{ ...look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change'), changes: '["name"]' }]); + + expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({ + state: 'possible_change', + changes: '["name"]', + external_updated: '2026-10-02', + seen_hash: 'seen.hash.now', + seen_updated: '2026-10-05', + checked_at: '2026-10-19 08:00:00', + }); + await repo.record([]); + }); + + it('THREPO-004: a manual check clears the hint for the importer only, and starts a row when there was none', async () => { + const { user } = await makeUser(t); + const { user: member } = await makeUser(t); + const trip = await makeTrip(t, user.id); + const placeId = await snapshot(trip.id, user.id, 'a'); + const seen = { contentHash: 'x.y.z', externalUpdated: '2026-10-10', checkedAt: '2026-10-10 09:00:00' }; + + await repo.markSeen(placeId, member.id, seen); + expect(await findRow(t, TourSourceHints, { place: placeId })).toBeNull(); + + await repo.markSeen(placeId, user.id, seen); + expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({ state: null, seen_hash: 'x.y.z' }); + + await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]); + await repo.markSeen(placeId, user.id, { ...seen, contentHash: 'x.y.w', externalUpdated: null }); + expect(await findRow(t, TourSourceHints, { place: placeId })).toMatchObject({ + state: null, + changes: null, + seen_hash: 'x.y.w', + seen_updated: null, + }); + await repo.markSeen(999_999, user.id, seen); + }); + + it('THREPO-005: open hints show while the connection points at their instance, newest look first', async () => { + const { user } = await makeUser(t); + const trip = await makeTrip(t, user.id, { title: 'Alps' }); + const first = await snapshot(trip.id, user.id, 'a'); + const second = await snapshot(trip.id, user.id, 'b'); + const quiet = await snapshot(trip.id, user.id, 'c'); + await repo.record([ + look(first, user.id, '2026-10-18 08:00:00', 'possible_change'), + look(second, user.id, '2026-10-19 08:00:00', 'possible_change'), + look(quiet, user.id, '2026-10-19 09:00:00'), + ]); + + expect(await repo.listOpen(user.id)).toEqual([]); + await connect(user.id); + const open = await repo.listOpen(user.id); + expect(open.map((row) => row.place_id)).toEqual([second, first]); + expect(open[0]).toMatchObject({ + trip_id: trip.id, + trip_title: 'Alps', + tour_name: 'Trail b', + state: 'possible_change', + }); + + expect(await repo.findOpen(user.id, trip.id, first)).toMatchObject({ place_id: first }); + expect(await repo.findOpen(user.id, trip.id, quiet)).toBeUndefined(); + expect(await repo.findOpen(user.id, trip.id + 1, first)).toBeUndefined(); + }); + + it('THREPO-006: a connection to another instance, or a trip the user left, hides the hint', async () => { + const { user } = await makeUser(t); + const { user: owner } = await makeUser(t); + const trip = await makeTrip(t, owner.id); + await addTripMember(t, trip.id, user.id); + const placeId = await snapshot(trip.id, user.id, 'a'); + await repo.record([look(placeId, user.id, '2026-10-19 08:00:00', 'possible_change')]); + await connect(user.id, 'https://elsewhere.example'); + expect(await repo.listOpen(user.id)).toEqual([]); + + await updateRows(t, WandererConnections, { user: user.id }, { url: INSTANCE }); + expect(await repo.listOpen(user.id)).toHaveLength(1); + await deleteRows(t, TripMembers, { trip: trip.id, user: user.id }); + expect(await repo.listOpen(user.id)).toEqual([]); + expect(await repo.listDue(user.id, INSTANCE, '2026-10-20 00:00:00', 10)).toEqual([]); + }); +}); diff --git a/server/tests/unit/db/repositories/WandererConnections.repository.test.ts b/server/tests/unit/db/repositories/WandererConnections.repository.test.ts index 094373b53..861edbea1 100644 --- a/server/tests/unit/db/repositories/WandererConnections.repository.test.ts +++ b/server/tests/unit/db/repositories/WandererConnections.repository.test.ts @@ -92,6 +92,68 @@ describe('WandererConnectionsRepository', () => { expect(await connections.findRow(user.id)).toBeNull(); expect(await connections.findRow(other.id)).not.toBeNull(); }); + + const connect = (id: number) => + connections.save(id, { url: 'https://a.example', allowInsecureTls: false, token: 'enc', hostChanged: false }); + + it('WANDREPO-004: a new token forgets what the last check said about the old one', async () => { + const { user } = createUser(testDb); + await connect(user.id); + await connections.recordCheck(user.id, false, 'unauthorized'); + await connect(user.id); + expect(await connections.findRow(user.id)).toMatchObject({ last_check_ok: null, last_check_error: null }); + }); + + it('WANDREPO-005: the watch is off by default, and switching it on starts without an old pause', async () => { + const { user } = createUser(testDb); + expect(await connections.findWatch(user.id)).toBeUndefined(); + await connect(user.id); + expect(await connections.findWatch(user.id)).toEqual({ + watch_enabled: 0, + watch_last_run_at: null, + watch_failures: 0, + watch_paused_until: null, + }); + + await connections.recordWatchRun(user.id, { + lastRunAt: '2026-10-19 10:00:00', + failures: 2, + pausedUntil: '2026-10-19 12:00:00', + }); + await connections.setWatch(user.id, true); + expect(await connections.findWatch(user.id)).toEqual({ + watch_enabled: 1, + watch_last_run_at: '2026-10-19 10:00:00', + watch_failures: 0, + watch_paused_until: null, + }); + await connections.setWatch(user.id, false); + expect((await connections.findWatch(user.id))?.watch_enabled).toBe(0); + }); + + it('WANDREPO-006: only switched-on connections with an accepted token and no pause are watchable, longest waiting first', async () => { + const ids = Array.from({ length: 6 }, () => createUser(testDb).user.id); + await Promise.all( + ids.map(async (id) => { + await connect(id); + await connections.setWatch(id, true); + }), + ); + const [never, ranEarly, ranLate, off, refused, paused] = ids; + await connections.recordWatchRun(ranEarly, { lastRunAt: '2026-10-19 08:00:00', failures: 0, pausedUntil: null }); + await connections.recordWatchRun(ranLate, { lastRunAt: '2026-10-19 09:00:00', failures: 0, pausedUntil: null }); + await connections.setWatch(off, false); + await connections.recordCheck(refused, false, 'unauthorized'); + await connections.recordWatchRun(paused, { + lastRunAt: '2026-10-19 07:00:00', + failures: 1, + pausedUntil: '2026-10-19 11:00:00', + }); + + expect(await connections.listWatchable('2026-10-19 10:00:00', 10)).toEqual([never, ranEarly, ranLate]); + expect(await connections.listWatchable('2026-10-19 12:00:00', 10)).toEqual([never, paused, ranEarly, ranLate]); + expect(await connections.listWatchable('2026-10-19 12:00:00', 2)).toEqual([never, paused]); + }); }); describe('TourSourcesRepository', () => { diff --git a/server/tests/unit/nest/notification-preferences.service.test.ts b/server/tests/unit/nest/notification-preferences.service.test.ts index e843bf08d..5be317c6b 100644 --- a/server/tests/unit/nest/notification-preferences.service.test.ts +++ b/server/tests/unit/nest/notification-preferences.service.test.ts @@ -151,14 +151,15 @@ describe('getPreferencesMatrix', () => { const { event_types } = await getPreferencesMatrix(user.id, 'user'); expect(event_types).not.toContain('version_available'); // +1 for plugin_notification: users can mute host-mediated plugin notifications. - expect(event_types.length).toBe(12); + // +1 for wanderer_source_hint: the hints of the user's own wanderer watch (WAN-02). + expect(event_types.length).toBe(13); }); it('NPREF-005 — user scope excludes version_available for everyone including admins', async () => { const { user } = createAdmin(testDb); const { event_types } = await getPreferencesMatrix(user.id, 'admin', 'user'); expect(event_types).not.toContain('version_available'); - expect(event_types.length).toBe(12); + expect(event_types.length).toBe(13); }); it('NPREF-005b — admin scope returns the admin-scoped events', async () => { diff --git a/server/tests/unit/nest/notifications.service.test.ts b/server/tests/unit/nest/notifications.service.test.ts index 22e295cf1..e207c8b9c 100644 --- a/server/tests/unit/nest/notifications.service.test.ts +++ b/server/tests/unit/nest/notifications.service.test.ts @@ -580,6 +580,34 @@ describe('send() — in-app notification content', () => { expect(notifs.length).toBe(1); expect(notifs[0].text_key).toBe('notif.replica_failure.text'); }); + + it('NOTIF-WAN-001 a wanderer hint names its state in the text and leads to its tour (WAN-02)', async () => { + const { user } = createUser(testDb); + setNotificationChannels(testDb, 'none'); + const hint = (state: string, placeId: string) => + send({ + event: 'wanderer_source_hint', + actorId: null, + scope: 'user', + targetId: user.id, + params: { state, tour: 'Ridge', trip: 'Alps', tripId: '5', placeId }, + }); + await hint('possible_change', '11'); + await hint('changed', '12'); + await hint('unverifiable', '13'); + await hint('changed', ''); + + const notifs = await getInAppNotifications(user.id); + expect(notifs.map((n) => [n.text_key, n.navigate_target])).toEqual( + expect.arrayContaining([ + ['notif.wanderer_source_hint.possible_change', '/trips/5?tab=plan&place=11'], + ['notif.wanderer_source_hint.changed', '/trips/5?tab=plan&place=12'], + ['notif.wanderer_source_hint.unverifiable', '/trips/5?tab=plan&place=13'], + ['notif.wanderer_source_hint.changed', null], + ]), + ); + expect(notifs.every((n) => n.title_key === 'notif.wanderer_source_hint.title')).toBe(true); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/server/tests/unit/nest/wanderer-watch.controller.test.ts b/server/tests/unit/nest/wanderer-watch.controller.test.ts new file mode 100644 index 000000000..6eb44c398 --- /dev/null +++ b/server/tests/unit/nest/wanderer-watch.controller.test.ts @@ -0,0 +1,79 @@ +/** + * WandererWatchController (WAN-02) and the hint route of + * ToursWandererController: their route metadata and their delegation to + * WandererWatchService. The watch has no MCP tool, as no part of the wanderer + * connection has one: it runs on a personal credential that never leaves the + * server. The hint of a tour asks 'place_edit', the right of the check it + * leads to. + */ +import 'reflect-metadata'; + +import { AddonGuard } from '../../../src/nest/addons/addon.guard'; +import { REQUIRE_ADDON } from '../../../src/nest/addons/require-addon.decorator'; +import { JwtAuthGuard } from '../../../src/nest/auth-core/jwt-auth.guard'; +import { TRIP_PERMISSION_KEY, TripAccessGuard } from '../../../src/nest/permissions/trip-access.guard'; +import { ToursWandererController } from '../../../src/nest/tours/tours-wanderer.controller'; +import type { ToursWandererService } from '../../../src/nest/tours/tours-wanderer.service'; +import { WandererWatchController } from '../../../src/nest/tours/wanderer-watch.controller'; +import type { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service'; +import { WandererEnabledGuard } from '../../../src/nest/wanderer/wanderer-enabled.guard'; +import type { User } from '../../../src/types'; +import { RequestMethod } from '@nestjs/common'; +import { GUARDS_METADATA, METHOD_METADATA, PATH_METADATA } from '@nestjs/common/constants'; + +import type { Request } from 'express'; +import { describe, expect, it, vi } from 'vitest'; + +const STATUS = { enabled: true, connected: true, lastRunAt: null, pausedUntil: null, blocked: false, hints: [] }; + +function watchService() { + return { + status: vi.fn(async () => STATUS), + setEnabled: vi.fn(async () => STATUS), + hintFor: vi.fn(async () => ({ hint: null })), + }; +} + +describe('WandererWatchController', () => { + it('WWCTL-001: sits beside the connection, behind Tours, auth and the wanderer switch', () => { + expect(Reflect.getMetadata(PATH_METADATA, WandererWatchController)).toBe('api/integrations/wanderer/watch'); + expect(Reflect.getMetadata(GUARDS_METADATA, WandererWatchController)).toEqual([ + AddonGuard, + JwtAuthGuard, + WandererEnabledGuard, + ]); + expect(Reflect.getMetadata(REQUIRE_ADDON, WandererWatchController)).toEqual({ addonId: 'tours', label: 'Tours' }); + expect(Reflect.getMetadata(METHOD_METADATA, WandererWatchController.prototype.status)).toBe(RequestMethod.GET); + expect(Reflect.getMetadata(METHOD_METADATA, WandererWatchController.prototype.setEnabled)).toBe(RequestMethod.PUT); + }); + + it('WWCTL-002: reads and switches only the caller own watch', async () => { + const service = watchService(); + const controller = new WandererWatchController(service as unknown as WandererWatchService); + const user = { id: 4 } as User; + expect(await controller.status(user)).toBe(STATUS); + expect(service.status).toHaveBeenCalledExactlyOnceWith(4); + const req = { headers: {}, socket: { remoteAddress: '10.0.0.9' } } as unknown as Request; + expect(await controller.setEnabled(user, { enabled: false }, req)).toBe(STATUS); + expect(service.setEnabled).toHaveBeenCalledExactlyOnceWith(4, false, expect.anything()); + }); +}); + +describe('ToursWandererController hint route', () => { + it('WWCTL-003: asks place_edit like the check, and hands trip, user and tour to the watch', async () => { + const fn = ToursWandererController.prototype.hint; + expect(Reflect.getMetadata(METHOD_METADATA, fn)).toBe(RequestMethod.GET); + expect(Reflect.getMetadata(PATH_METADATA, fn)).toBe('sources/:placeId/hint'); + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, fn)).toBe('place_edit'); + expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, ToursWandererController.prototype.checkSource)).toBe('place_edit'); + expect(Reflect.getMetadata(GUARDS_METADATA, ToursWandererController)).toContain(TripAccessGuard); + + const service = watchService(); + const controller = new ToursWandererController( + {} as ToursWandererService, + service as unknown as WandererWatchService, + ); + expect(await controller.hint('7', '3', { id: 4 } as User)).toEqual({ hint: null }); + expect(service.hintFor).toHaveBeenCalledExactlyOnceWith('7', 4, '3'); + }); +}); diff --git a/server/tests/unit/nest/wanderer-watch.helpers.test.ts b/server/tests/unit/nest/wanderer-watch.helpers.test.ts new file mode 100644 index 000000000..e0c53e155 --- /dev/null +++ b/server/tests/unit/nest/wanderer-watch.helpers.test.ts @@ -0,0 +1,124 @@ +/** + * WAN-02: the pure half of the wanderer watch: what a look at a trail's record + * says about a snapshot, when a hint is new, and how long a failing instance + * is left alone. + */ +import type { WatchedSourceRow } from '../../../src/db/repositories/TourSourceHints.repository'; +import { contentHashOf, recordChangedParts } from '../../../src/nest/tours/tours.helpers'; +import { + backoffMs, + isNewHint, + verdictOf, + WATCH_BACKOFF_BASE_MS, + WATCH_BACKOFF_MAX_MS, +} from '../../../src/nest/tours/wanderer-watch.helpers'; + +import { describe, expect, it } from 'vitest'; + +const ROUTE: [number, number][] = [ + [47.45, 11.1], + [47.46, 11.11], +]; +const HASH = contentHashOf('Ridge', 'Up the ridge', ROUTE); + +function source(over: Partial = {}): WatchedSourceRow { + return { + place_id: 3, + external_id: 'trail1', + content_hash: HASH, + external_updated: '2026-10-01 10:00:00.000Z', + seen_hash: null, + seen_updated: null, + state: null, + ...over, + }; +} + +const record = (over: Partial<{ name: string; description: string | null; updated: string | null }> = {}) => ({ + kind: 'record' as const, + name: 'Ridge', + description: 'Up the ridge', + updated: '2026-10-01 10:00:00.000Z', + ...over, +}); + +describe('recordChangedParts', () => { + it('WWH-001: names the texts a record proves different, never the route', () => { + expect(recordChangedParts(HASH, ' Ridge ', 'Up the ridge')).toEqual([]); + expect(recordChangedParts(HASH, 'Ridge (new)', null)).toEqual(['name', 'description']); + expect(recordChangedParts(contentHashOf('Ridge', null, ROUTE), 'Ridge', '')).toEqual([]); + }); +}); + +describe('verdictOf', () => { + it('WWH-002: the same texts and change time say nothing, least of all unchanged', () => { + expect(verdictOf(source(), record())).toEqual({ state: null, changes: [], reason: null }); + expect(verdictOf(source(), record({ updated: '2026-10-01T10:00:00Z' }))).toEqual({ + state: null, + changes: [], + reason: null, + }); + }); + + it('WWH-003: a moved change time is only a possible change', () => { + expect(verdictOf(source(), record({ updated: '2026-10-14 08:00:00.000Z' }))).toEqual({ + state: 'possible_change', + changes: [], + reason: null, + }); + }); + + it('WWH-004: a different name or description is a proven change', () => { + expect(verdictOf(source(), record({ name: 'Ridge (new)', updated: '2026-10-14' }))).toEqual({ + state: 'changed', + changes: ['name'], + reason: null, + }); + }); + + it('WWH-005: a trail that is gone, or a change time missing on either side, cannot be judged', () => { + const unavailable = { state: 'unverifiable', changes: [], reason: 'unavailable' }; + expect(verdictOf(source(), { kind: 'gone' })).toEqual(unavailable); + expect(verdictOf(source(), undefined)).toEqual(unavailable); + const noTime = { state: 'unverifiable', changes: [], reason: 'no_change_time' }; + expect(verdictOf(source({ external_updated: null }), record())).toEqual(noTime); + expect(verdictOf(source(), record({ updated: null }))).toEqual(noTime); + expect(verdictOf(source(), record({ updated: 'not a date' }))).toMatchObject({ state: 'possible_change' }); + }); + + it('WWH-006: after a manual check the look compares with what the user saw there', () => { + const seen = source({ + seen_hash: contentHashOf('Ridge (new)', 'Up the ridge', ROUTE), + seen_updated: '2026-10-14 08:00:00.000Z', + }); + expect(verdictOf(seen, record({ name: 'Ridge (new)', updated: '2026-10-14 08:00:00.000Z' }))).toEqual({ + state: null, + changes: [], + reason: null, + }); + expect(verdictOf(seen, record({ name: 'Ridge (new)', updated: '2026-10-20 08:00:00.000Z' }))).toMatchObject({ + state: 'possible_change', + }); + expect(verdictOf(source({ seen_hash: HASH, seen_updated: null }), record())).toMatchObject({ + reason: 'no_change_time', + }); + }); +}); + +describe('isNewHint and backoffMs', () => { + it('WWH-007: a hint is new when there is something to say that the last look did not say', () => { + const possible = { state: 'possible_change' as const, changes: [], reason: null }; + expect(isNewHint(source(), possible)).toBe(true); + expect(isNewHint(source({ state: 'possible_change' }), possible)).toBe(false); + expect(isNewHint(source({ state: 'possible_change' }), { ...possible, state: 'changed' })).toBe(true); + expect(isNewHint(source({ state: 'changed' }), { state: null, changes: [], reason: null })).toBe(false); + }); + + it('WWH-008: each failure in a row doubles the wait, up to its ceiling', () => { + expect(backoffMs(1)).toBe(WATCH_BACKOFF_BASE_MS); + expect(backoffMs(2)).toBe(2 * WATCH_BACKOFF_BASE_MS); + expect(backoffMs(4)).toBe(8 * WATCH_BACKOFF_BASE_MS); + expect(backoffMs(30)).toBe(WATCH_BACKOFF_MAX_MS); + expect(backoffMs(0)).toBe(WATCH_BACKOFF_BASE_MS); + }); +}); diff --git a/server/tests/unit/nest/wanderer-watch.job.test.ts b/server/tests/unit/nest/wanderer-watch.job.test.ts new file mode 100644 index 000000000..3d280b2fa --- /dev/null +++ b/server/tests/unit/nest/wanderer-watch.job.test.ts @@ -0,0 +1,59 @@ +/** + * WAN-02: the hourly provider of the wanderer watch: registration, the test + * gate, and that a failing run cannot take the scheduler down with it. + */ +import type { CronRegistrarService } from '../../../src/nest/scheduling/cron-registrar.service'; +import { WandererWatchJob } from '../../../src/nest/tours/wanderer-watch.job'; +import type { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const logMock = vi.hoisted(() => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), + logDebug: vi.fn(), +})); +vi.mock('../../../src/nest/audit/audit-log.logger', () => logMock); + +function makeJob(run: () => Promise, enabled = true) { + const registrar = { isEnabled: vi.fn(() => enabled), register: vi.fn(() => enabled), unregister: vi.fn() }; + const watch = { run: vi.fn(run) }; + const job = new WandererWatchJob( + watch as unknown as WandererWatchService, + registrar as unknown as CronRegistrarService, + ); + return { job, registrar, watch }; +} + +beforeEach(() => vi.clearAllMocks()); + +describe('WandererWatchJob', () => { + it('WWJOB-001: registers hourly at minute 23', () => { + const { job, registrar } = makeJob(async () => undefined); + job.onApplicationBootstrap(); + expect(registrar.register).toHaveBeenCalledWith('wanderer-watch', '23 * * * *', expect.any(Function)); + }); + + it('WWJOB-002: does not register under the test gate', () => { + const { job, registrar } = makeJob(async () => undefined, false); + job.onApplicationBootstrap(); + expect(registrar.register).not.toHaveBeenCalled(); + }); + + it('WWJOB-003: a tick runs the watch and contains its failure', async () => { + const ok = makeJob(async () => undefined); + await ok.job.tick(); + expect(ok.watch.run).toHaveBeenCalledOnce(); + + const failing = makeJob(async () => { + throw new Error('database is locked'); + }); + await expect(failing.job.tick()).resolves.toBeUndefined(); + expect(logMock.logError).toHaveBeenCalledWith(expect.stringContaining('database is locked')); + const odd = makeJob(() => Promise.reject('plain')); + await odd.job.tick(); + expect(logMock.logError).toHaveBeenLastCalledWith(expect.stringContaining('plain')); + }); +}); diff --git a/server/tests/unit/nest/wanderer-watch.service.test.ts b/server/tests/unit/nest/wanderer-watch.service.test.ts new file mode 100644 index 000000000..ff3491011 --- /dev/null +++ b/server/tests/unit/nest/wanderer-watch.service.test.ts @@ -0,0 +1,323 @@ +/** + * WandererWatchService (WAN-02): the optional background watch over a user's + * own wanderer snapshots. The service comes out of ToursModule over a snapshot + * test db with its real repositories; only the wanderer client (no socket is + * ever opened) and the notifications (a spy) are replaced. + */ +import { Places } from '../../../src/db/entities/Places.entity'; +import { TourSourceHints } from '../../../src/db/entities/TourSourceHints.entity'; +import { WandererConnections } from '../../../src/db/entities/WandererConnections.entity'; +import { maybe_encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto'; +import { DomainError } from '../../../src/nest/common/domain-error'; +import { NotificationsService } from '../../../src/nest/notifications/notifications.service'; +import { contentHashOf } from '../../../src/nest/tours/tours.helpers'; +import { WATCH_BACKOFF_BASE_MS } from '../../../src/nest/tours/wanderer-watch.helpers'; +import { WandererWatchService } from '../../../src/nest/tours/wanderer-watch.service'; +import { ToursModule } from '../../../src/nest/tours/tours.module'; +import { WandererClient, WandererError } from '../../../src/nest/wanderer/wanderer.client'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { makePlace } from '../../helpers/factories/places'; +import { countRows, deleteRows, findRow, insertRow, updateRows } from '../../helpers/factories/rows'; +import { setAddonEnabled, setAppSetting } from '../../helpers/factories/settings'; +import { makeTour, makeTourSource } from '../../helpers/factories/tours'; +import { makeTrip } from '../../helpers/factories/trips'; +import { makeUser } from '../../helpers/factories/users'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { MikroORM } from '@mikro-orm/core'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/nest/audit/audit-log.logger', () => ({ + LOG_LEVEL: 'error', + logInfo: vi.fn(), + logDebug: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), +})); + +const INSTANCE = 'https://wanderer.example.org'; +const ROUTE: [number, number][] = [ + [47.45, 11.1], + [47.46, 11.11], +]; +const DAY = 24 * 60 * 60 * 1000; +const NOW = new Date('2026-10-20T10:00:00Z'); +const later = (ms: number) => new Date(NOW.getTime() + ms); + +const RECORD = { + id: 'trail1', + name: 'Ridge', + description: 'Up the ridge', + gpx: 'track.gpx', + updated: '2026-10-01 10:00:00.000Z', + author: 'actor1', +}; +const client = { + check: vi.fn(), + ownActors: vi.fn(), + search: vi.fn(), + searchById: vi.fn(), + trail: vi.fn(), + gpx: vi.fn(), +}; +const notifications = { send: vi.fn() }; + +const db = createSnapshotTestDb(); +let t: TestModule; +let watch: WandererWatchService; +const orm = () => t.get(MikroORM); + +beforeAll(async () => { + t = await createTestModule({ + db, + imports: [ToursModule], + overrides: [ + { provide: WandererClient, useValue: client }, + { provide: NotificationsService, useValue: notifications }, + ], + }); + watch = t.get(WandererWatchService); +}); +beforeEach(async () => { + resetTestDb(db); + vi.clearAllMocks(); + client.ownActors.mockResolvedValue(['actor1']); + client.trail.mockResolvedValue(RECORD); + notifications.send.mockResolvedValue({ attempted: 1, delivered: 1 }); + await setAddonEnabled(orm(), 'tours', true); + await setAppSetting(orm(), 'tours_wanderer_enabled', 'true'); +}); +afterAll(async () => { + await t.close(); + db.close(); +}); + +/** A user with a watched connection and one wanderer snapshot, imported as RECORD said then. */ +async function fixture(watchEnabled = true) { + const { user } = await makeUser(orm()); + await insertRow(orm(), WandererConnections, { + user: user.id, + url: INSTANCE, + token: maybe_encrypt_api_key('wanderer_key_abc'), + watch_enabled: watchEnabled ? 1 : 0, + }); + const trip = await makeTrip(orm(), user.id, { title: 'Alps' }); + const place = await makePlace(orm(), trip.id, { name: 'Ridge' }); + await makeTour(orm(), place.id, { origin: 'wanderer' }); + await makeTourSource(orm(), place.id, user.id, { + instance: INSTANCE, + content_hash: contentHashOf(RECORD.name, RECORD.description, ROUTE), + external_updated: RECORD.updated, + }); + return { user, trip, placeId: place.id }; +} + +const hintRow = (placeId: number) => findRow(orm(), TourSourceHints, { place: placeId }); +const connection = (userId: number) => findRow(orm(), WandererConnections, { user: userId }); + +describe('WandererWatchService.run', () => { + it('WWATCH-001: asks nothing while the watch is off, or wanderer is off', async () => { + await fixture(false); + await watch.run(NOW); + expect(client.ownActors).not.toHaveBeenCalled(); + + const { user } = await fixture(true); + await setAppSetting(orm(), 'tours_wanderer_enabled', 'false'); + await watch.run(NOW); + expect(client.ownActors).not.toHaveBeenCalled(); + expect(await connection(user.id)).toMatchObject({ watch_last_run_at: null }); + }); + + it('WWATCH-002: a record as imported writes a quiet look, tells nobody and never fetches a GPX', async () => { + const { user, placeId } = await fixture(); + const before = await countRows(orm(), Places); + await watch.run(NOW); + expect(client.trail).toHaveBeenCalledTimes(1); + expect(client.gpx).not.toHaveBeenCalled(); + expect(await hintRow(placeId)).toMatchObject({ state: null, checked_at: '2026-10-20 10:00:00' }); + expect(await connection(user.id)).toMatchObject({ watch_last_run_at: '2026-10-20 10:00:00', watch_failures: 0 }); + expect(notifications.send).not.toHaveBeenCalled(); + expect(await countRows(orm(), Places)).toBe(before); + expect((await watch.status(user.id)).hints).toEqual([]); + }); + + it('WWATCH-003: a moved change time leaves a possible change, told once, and a source rests a day', async () => { + const { user, trip, placeId } = await fixture(); + client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14 08:00:00.000Z' }); + await watch.run(NOW); + expect(await hintRow(placeId)).toMatchObject({ + state: 'possible_change', + external_updated: '2026-10-14 08:00:00.000Z', + }); + expect(notifications.send).toHaveBeenCalledExactlyOnceWith({ + event: 'wanderer_source_hint', + actorId: null, + scope: 'user', + targetId: user.id, + params: { + state: 'possible_change', + tour: 'Ridge', + trip: 'Alps', + tripId: String(trip.id), + placeId: String(placeId), + }, + }); + + await watch.run(later(DAY / 2)); + expect(client.trail).toHaveBeenCalledTimes(1); + // A second past the day: the stored times keep whole seconds. + await watch.run(later(DAY + 1000)); + expect(client.trail).toHaveBeenCalledTimes(2); + expect(notifications.send).toHaveBeenCalledTimes(1); + }); + + it('WWATCH-004: a different name is a proven change, named as such', async () => { + const { placeId } = await fixture(); + client.trail.mockResolvedValue({ ...RECORD, name: 'Ridge (new)', updated: '2026-10-14' }); + await watch.run(NOW); + expect(await hintRow(placeId)).toMatchObject({ state: 'changed', changes: '["name"]', reason: null }); + expect(notifications.send.mock.calls[0]?.[0]).toMatchObject({ params: { state: 'changed' } }); + }); + + it('WWATCH-005: a trail that is gone or no longer the user own cannot be judged, and the tour stays', async () => { + const { placeId } = await fixture(); + client.trail.mockRejectedValue(new WandererError('not_found', 'gone')); + await watch.run(NOW); + expect(await hintRow(placeId)).toMatchObject({ state: 'unverifiable', reason: 'unavailable' }); + expect(await findRow(orm(), Places, { id: placeId })).not.toBeNull(); + }); + + it('WWATCH-006: a failing instance gets nothing written and is left alone for longer each time', async () => { + const { user, placeId } = await fixture(); + client.trail.mockRejectedValue(new WandererError('server_error', 'HTTP 429')); + await watch.run(NOW); + expect(await hintRow(placeId)).toBeNull(); + expect(await connection(user.id)).toMatchObject({ watch_failures: 1, watch_paused_until: '2026-10-20 11:00:00' }); + + await watch.run(later(WATCH_BACKOFF_BASE_MS / 2)); + expect(client.trail).toHaveBeenCalledTimes(1); + await watch.run(later(WATCH_BACKOFF_BASE_MS)); + expect(await connection(user.id)).toMatchObject({ watch_failures: 2, watch_paused_until: '2026-10-20 13:00:00' }); + + client.trail.mockResolvedValue(RECORD); + await watch.run(later(4 * WATCH_BACKOFF_BASE_MS)); + expect(await connection(user.id)).toMatchObject({ watch_failures: 0, watch_paused_until: null }); + }); + + it('WWATCH-007: a refused token stops the watch until a new check passes', async () => { + const { user } = await fixture(); + client.ownActors.mockRejectedValue(new WandererError('unauthorized', 'revoked')); + await watch.run(NOW); + expect((await watch.status(user.id)).blocked).toBe(true); + await watch.run(later(DAY * 3)); + expect(client.ownActors).toHaveBeenCalledTimes(1); + }); + + it('WWATCH-008: a removed connection ends the looks; the snapshot and its hint stay unread', async () => { + const { user, placeId } = await fixture(); + client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' }); + await watch.run(NOW); + await deleteRows(orm(), WandererConnections, { user: user.id }); + await watch.run(later(DAY * 2)); + expect(client.trail).toHaveBeenCalledTimes(1); + expect(await hintRow(placeId)).toMatchObject({ state: 'possible_change' }); + expect(await watch.status(user.id)).toMatchObject({ connected: false, hints: [] }); + }); + + it('WWATCH-009: nothing due asks the instance nothing, and a failed notice is only logged', async () => { + const { user } = await fixture(); + await watch.run(NOW); + client.ownActors.mockClear(); + await watch.run(later(DAY / 4)); + expect(client.ownActors).not.toHaveBeenCalled(); + expect(await connection(user.id)).toMatchObject({ watch_last_run_at: '2026-10-20 16:00:00' }); + + const other = await fixture(); + notifications.send.mockRejectedValue(new Error('smtp down')); + client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' }); + await expect(watch.run(later(DAY / 2))).resolves.toBeUndefined(); + expect(await hintRow(other.placeId)).toMatchObject({ state: 'possible_change' }); + }); + + it('WWATCH-010: a run already going is not started twice', async () => { + await fixture(); + let release: () => void = () => undefined; + client.ownActors.mockImplementation( + () => + new Promise((resolve) => { + release = () => resolve(['actor1']); + }), + ); + const first = watch.run(NOW); + await vi.waitFor(() => expect(client.ownActors).toHaveBeenCalled()); + await watch.run(NOW); + release(); + await first; + expect(client.ownActors).toHaveBeenCalledTimes(1); + }); +}); + +describe('WandererWatchService switch, status and hints', () => { + it('WWATCH-011: switching on needs a connection; the status lists the open hints with their tour', async () => { + const { user: stranger } = await makeUser(orm()); + await expect(watch.setEnabled(stranger.id, true, null)).rejects.toBeInstanceOf(DomainError); + + const { user, trip, placeId } = await fixture(false); + expect(await watch.setEnabled(user.id, true, null)).toMatchObject({ enabled: true, connected: true, hints: [] }); + client.trail.mockResolvedValue({ ...RECORD, updated: '2026-10-14' }); + await watch.run(NOW); + expect(await watch.status(user.id)).toMatchObject({ + enabled: true, + lastRunAt: '2026-10-20T10:00:00Z', + pausedUntil: null, + blocked: false, + hints: [ + { + placeId, + tripId: trip.id, + tripTitle: 'Alps', + tourName: 'Ridge', + state: 'possible_change', + changes: [], + reason: null, + externalUpdated: '2026-10-14', + checkedAt: '2026-10-20T10:00:00Z', + }, + ], + }); + expect(await watch.setEnabled(user.id, false, null)).toMatchObject({ enabled: false }); + await watch.run(later(DAY * 2)); + expect(client.trail).toHaveBeenCalledTimes(1); + }); + + it('WWATCH-012: the hint of one tour is the caller own, and a manual check clears it', async () => { + const { user, trip, placeId } = await fixture(); + const { user: member } = await makeUser(orm()); + client.trail.mockResolvedValue({ ...RECORD, name: 'Ridge (new)', updated: '2026-10-14' }); + await watch.run(NOW); + + expect(await watch.hintFor(String(trip.id), user.id, String(placeId))).toEqual({ + hint: expect.objectContaining({ state: 'changed', changes: ['name'] }), + }); + expect(await watch.hintFor(String(trip.id), member.id, String(placeId))).toEqual({ hint: null }); + expect(await watch.hintFor('x', user.id, String(placeId))).toEqual({ hint: null }); + + await watch.noteSeen(placeId, user.id, { + contentHash: contentHashOf('Ridge (new)', RECORD.description, ROUTE), + externalUpdated: '2026-10-14', + }); + expect(await watch.hintFor(String(trip.id), user.id, String(placeId))).toEqual({ hint: null }); + await watch.run(later(DAY * 2)); + expect(await hintRow(placeId)).toMatchObject({ state: null, seen_updated: '2026-10-14' }); + expect(notifications.send).toHaveBeenCalledTimes(1); + }); + + it('WWATCH-013: a stored change list that is broken reads as none', async () => { + const { user, trip, placeId } = await fixture(); + client.trail.mockResolvedValue({ ...RECORD, name: 'Other' }); + await watch.run(NOW); + await updateRows(orm(), TourSourceHints, { place: placeId }, { changes: 'not json' }); + expect((await watch.hintFor(String(trip.id), user.id, String(placeId))).hint).toMatchObject({ changes: [] }); + }); +}); diff --git a/server/tests/unit/nest/wanderer.service.test.ts b/server/tests/unit/nest/wanderer.service.test.ts index 889df226e..e19de592e 100644 --- a/server/tests/unit/nest/wanderer.service.test.ts +++ b/server/tests/unit/nest/wanderer.service.test.ts @@ -39,12 +39,18 @@ function hit(over: Partial = {}): WandererSearchHit { return { id: 'trail1', name: 'Ridge', ...over }; } -const state: { row: Row } = { row: undefined }; +type Watch = Awaited>; + +const state: { row: Row; watch: Watch } = { row: undefined, watch: undefined }; const connections = { findRow: vi.fn(async () => state.row), save: vi.fn(async () => undefined), remove: vi.fn(async () => undefined), recordCheck: vi.fn(async () => undefined), + findWatch: vi.fn(async () => state.watch), + setWatch: vi.fn(async () => undefined), + listWatchable: vi.fn(async () => [3, 4]), + recordWatchRun: vi.fn(async () => undefined), }; const client = { check: vi.fn(), @@ -77,8 +83,9 @@ afterAll(async () => { }); /** The service over a saved row (or none), every collaborator answering the happy path. */ -function setup(row: Row = undefined) { +function setup(row: Row = undefined, watch: Watch = undefined) { state.row = row; + state.watch = watch; for (const mock of [...Object.values(connections), ...Object.values(client), audit.writeAudit]) mock.mockClear(); client.check.mockReset().mockResolvedValue({ trailCount: 4 }); client.ownActors.mockReset().mockResolvedValue(['actor1']); @@ -375,3 +382,99 @@ describe('WandererService reads for an import', () => { await expect(service.download(1, 'trail1')).rejects.toThrow(TypeError); }); }); + +describe('WandererService background watch (WAN-02)', () => { + it('WAND-SVC-020: the watch state reads the switch, the last run and a refused token', async () => { + const none = setup(); + expect(await none.service.watchState(1)).toEqual({ + connected: false, + enabled: false, + lastRunAt: null, + failures: 0, + pausedUntil: null, + blocked: false, + }); + const watched = setup(savedRow({ last_check_ok: 0, last_check_error: 'unauthorized' }), { + watch_enabled: 1, + watch_last_run_at: '2026-10-19 10:00:00', + watch_failures: 2, + watch_paused_until: '2026-10-19 14:00:00', + }); + expect(await watched.service.watchState(1)).toEqual({ + connected: true, + enabled: true, + lastRunAt: '2026-10-19 10:00:00', + failures: 2, + pausedUntil: '2026-10-19 14:00:00', + blocked: true, + }); + }); + + it('WAND-SVC-021: switching on needs a connection; either way the switch is audited', async () => { + const none = setup(); + expect((await refusal(none.service.setWatch(1, true, null))).getStatus()).toBe(409); + expect(none.connections.setWatch).not.toHaveBeenCalled(); + + const { service, connections, audit } = setup(savedRow()); + await service.setWatch(1, true, '10.0.0.1'); + expect(connections.setWatch).toHaveBeenCalledWith(1, true); + expect(audit.writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'wanderer.watch_enabled' })); + await setup().service.setWatch(1, false, null); + expect(connections.setWatch).toHaveBeenLastCalledWith(1, false); + expect(audit.writeAudit).toHaveBeenLastCalledWith(expect.objectContaining({ action: 'wanderer.watch_disabled' })); + }); + + it('WAND-SVC-022: lists the watchable users and notes a run as given', async () => { + const { service, connections } = setup(savedRow()); + expect(await service.watchableUserIds('2026-10-19 10:00:00', 5)).toEqual([3, 4]); + expect(connections.listWatchable).toHaveBeenCalledWith('2026-10-19 10:00:00', 5); + const run = { lastRunAt: '2026-10-19 10:00:00', failures: 0, pausedUntil: null }; + await service.noteWatchRun(1, run); + expect(connections.recordWatchRun).toHaveBeenCalledWith(1, run); + }); + + it('WAND-SVC-023: reads each trail record once, never a GPX, and calls the gone ones gone', async () => { + const { service, client } = setup(savedRow()); + client.trail.mockImplementation(async (_creds: unknown, id: string) => { + if (id === 'deleted') throw new WandererError('not_found', 'gone'); + if (id === 'hidden') throw new WandererError('forbidden', 'no'); + if (id === 'theirs') return { id, name: 'Theirs', author: 'stranger' }; + return { id, name: 'Ridge', description: 'Up', updated: '2026-10-02', author: 'actor1' }; + }); + const read = await service.lookAtTrails(1, ['trail1', 'trail1', 'deleted', 'hidden', 'theirs']); + expect(read.ok).toBe(true); + if (!read.ok) return; + expect(Object.fromEntries(read.looks)).toEqual({ + trail1: { kind: 'record', name: 'Ridge', description: 'Up', updated: '2026-10-02' }, + deleted: { kind: 'gone' }, + hidden: { kind: 'gone' }, + theirs: { kind: 'gone' }, + }); + expect(client.trail).toHaveBeenCalledTimes(4); + expect(client.gpx).not.toHaveBeenCalled(); + + client.trail.mockResolvedValue({ id: 'bare', name: 'Bare', author: 'actor1' }); + const bare = await service.lookAtTrails(1, ['bare']); + expect(bare.ok && bare.looks.get('bare')).toEqual({ + kind: 'record', + name: 'Bare', + description: null, + updated: null, + }); + }); + + it('WAND-SVC-024: a failing instance ends the look with its reason, and a refused token is noted', async () => { + const { service, client, connections } = setup(savedRow()); + client.trail.mockRejectedValue(new WandererError('unreachable', 'down')); + expect(await service.lookAtTrails(1, ['trail1'])).toEqual({ ok: false, code: 'unreachable' }); + expect(connections.recordCheck).not.toHaveBeenCalled(); + + client.ownActors.mockRejectedValue(new WandererError('unauthorized', 'revoked')); + expect(await service.lookAtTrails(1, ['trail1'])).toEqual({ ok: false, code: 'unauthorized' }); + expect(connections.recordCheck).toHaveBeenCalledWith(1, false, 'unauthorized'); + + client.ownActors.mockRejectedValue(new Error('bug')); + await expect(service.lookAtTrails(1, ['trail1'])).rejects.toThrow('bug'); + expect((await refusal(setup().service.lookAtTrails(1, ['trail1']))).getStatus()).toBe(409); + }); +}); diff --git a/shared/src/i18n/ar/externalNotifications.ts b/shared/src/i18n/ar/externalNotifications.ts index 6cff6e7ad..a8596dc0e 100644 --- a/shared/src/i18n/ar/externalNotifications.ts +++ b/shared/src/i18n/ar/externalNotifications.ts @@ -90,6 +90,15 @@ const ar: NotificationLocale = { title: 'تمت إعادة تعيين جلسة Synology', body: 'تغيّر حسابك أو رابط Synology. تم تسجيل خروجك من Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `درب wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `تغيّر «${p.tour}» في «${p.trip}» على wanderer. افتح الجولة وتحقق من مصدرها لترى كل التغييرات. لا يتغير شيء في الرحلة من تلقاء نفسه.` + : p.state === 'unverifiable' + ? `لم يعد بالإمكان التحقق من «${p.tour}» في «${p.trip}» على wanderer. تبقى الجولة في رحلتك كما هي.` + : `ربما تغيّر «${p.tour}» في «${p.trip}» على wanderer. افتح الجولة وتحقق من مصدرها. لا يتغير شيء في الرحلة من تلقاء نفسه.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/ar/index.ts b/shared/src/i18n/ar/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/ar/index.ts +++ b/shared/src/i18n/ar/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ar/notif.ts b/shared/src/i18n/ar/notif.ts index 6e71f6071..8f3aa1a29 100644 --- a/shared/src/i18n/ar/notif.ts +++ b/shared/src/i18n/ar/notif.ts @@ -45,6 +45,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'عرض Vacay', 'notif.action.view_collection': 'عرض المجموعة', 'notif.action.view_admin': 'الذهاب للإدارة', + 'notif.wanderer_source_hint.title': 'درب wanderer', + 'notif.wanderer_source_hint.possible_change': 'ربما تغيّر «{tour}» في {trip} على wanderer', + 'notif.wanderer_source_hint.changed': 'تغيّر «{tour}» في {trip} على wanderer', + 'notif.wanderer_source_hint.unverifiable': 'لم يعد بالإمكان التحقق من «{tour}» في {trip} على wanderer', + 'notif.action.view_tour': 'عرض الجولة', 'notif.action.view': 'عرض', 'notif.action.accept': 'قبول', 'notif.action.decline': 'رفض', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 0267a090a..138f25287 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -101,6 +101,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'صور مشتركة (Immich)', 'settings.notifyCollabMessage': 'رسائل الدردشة (Collab)', 'settings.notifyPackingTagged': 'قائمة الأمتعة: التعيينات', + 'settings.notifyWandererSourceHint': 'wanderer: تنبيهات حول دروبك', 'settings.notifyWebhook': 'إشعارات Webhook', 'settings.notifyVersionAvailable': 'إصدار جديد متاح', 'settings.notifyReplicaFailure': 'فشل النسخة المتماثلة للتخزين', diff --git a/shared/src/i18n/ar/wandererWatch.ts b/shared/src/i18n/ar/wandererWatch.ts new file mode 100644 index 000000000..c261ee284 --- /dev/null +++ b/shared/src/i18n/ar/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'التحقق من تغييرات دروبي', + 'wanderer.watch.hint': 'إشعار عندما يكون درب مستورد قد تغيّر.', + 'wanderer.watch.info': + 'مرة واحدة في اليوم على الأكثر، يقرأ TREK السجل المختصر لكل درب استوردته، ولا يقرأ ملف GPX الخاص به أبدًا. إنه يُشعرك فقط: أنت من يقرر قبول التغيير عبر التحقق من مصدر الجولة.', + 'wanderer.watch.notConnected': 'اربط wanderer أولًا', + 'wanderer.watch.on': 'تم تشغيل التحقق في الخلفية', + 'wanderer.watch.off': 'تم إيقاف التحقق في الخلفية', + 'wanderer.watch.failed': 'تعذّر تبديل التحقق في الخلفية', + 'wanderer.watch.lastRun': 'آخر تحقق {date}', + 'wanderer.watch.neverRun': 'لم يُتحقق بعد', + 'wanderer.watch.paused': 'متوقف مؤقتًا حتى {time}', + 'wanderer.watch.pausedHint': 'لم يستجب wanderer. بعد كل إخفاق ينتظر TREK مدة أطول قبل أن يسأل مجددًا.', + 'wanderer.watch.blocked': 'رُفض الرمز', + 'wanderer.watch.blockedHint': 'رفض wanderer رمز API. يستأنف التحقق عندما ينجح اختبار الاتصال أو يُحفظ رمز جديد.', + 'wanderer.watch.hints': 'تنبيهات', + 'wanderer.watch.hintsCount': '{count} تنبيه مفتوح', + 'wanderer.watch.hintsCount.zero': 'لا توجد تنبيهات مفتوحة', + 'wanderer.watch.hintsCount.one': 'تنبيه مفتوح واحد', + 'wanderer.watch.hintsCount.two': 'تنبيهان مفتوحان', + 'wanderer.watch.hintsCount.few': '{count} تنبيهات مفتوحة', + 'wanderer.watch.hintsCount.many': '{count} تنبيهًا مفتوحًا', + 'wanderer.watch.empty': 'لا توجد تنبيهات مفتوحة', + 'wanderer.watch.dialogTitle': 'تنبيهات حول دروبك', + 'wanderer.watch.openTour': 'فتح الجولة', + 'wanderer.watch.checkedAt': 'تم التحقق {date}', + 'wanderer.watch.noticeLabel': 'التحقق في الخلفية', + 'wanderer.watch.state.possible_change': 'ربما تغيّر', + 'wanderer.watch.state.changed': 'تغيّر', + 'wanderer.watch.state.unverifiable': 'لا يمكن التحقق', + 'wanderer.watch.stateHint.possible_change': + 'غيّر wanderer الطابع الزمني للدرب. وحده التحقق من المصدر يُظهر ما إذا كان المحتوى مختلفًا.', + 'wanderer.watch.stateHint.changed': 'يُظهر سجل wanderer اسمًا أو وصفًا آخر. يُظهر التحقق من المصدر كل ما تغيّر.', + 'wanderer.watch.stateHint.unverifiable': 'لم يتمكن TREK من تقييم هذا الدرب، لذا لا يُقال شيء عن تغيّره.', + 'wanderer.watch.reason.unavailable': 'الدرب غير متاح', + 'wanderer.watch.reason.no_change_time': 'لا يوجد طابع زمني', +}; +export default wandererWatch; diff --git a/shared/src/i18n/az/externalNotifications.ts b/shared/src/i18n/az/externalNotifications.ts index 1d0fbbb4c..bf147fc79 100644 --- a/shared/src/i18n/az/externalNotifications.ts +++ b/shared/src/i18n/az/externalNotifications.ts @@ -75,6 +75,15 @@ const az: NotificationLocale = { title: 'Synology sessiyası təmizləndi', body: 'Synology hesabınız və ya URL-iniz dəyişib. Synology Photos sessiyanız bağlanıb.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer marşrutu: ${p.tour}`, + body: + p.state === 'changed' + ? `"${p.trip}" içində "${p.tour}" wanderer-də dəyişdirildi. Bütün dəyişiklikləri görmək üçün turu aç və mənbəyini yoxla. Səfərdə heç nə özbaşına dəyişmir.` + : p.state === 'unverifiable' + ? `"${p.trip}" içində "${p.tour}" artıq wanderer-də yoxlanıla bilmir. Səfərindəki tur olduğu kimi qalır.` + : `"${p.trip}" içində "${p.tour}" wanderer-də dəyişmiş ola bilər. Turu aç və mənbəyini yoxla. Səfərdə heç nə özbaşına dəyişmir.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '', diff --git a/shared/src/i18n/az/index.ts b/shared/src/i18n/az/index.ts index eacf3e31c..ccbf91396 100644 --- a/shared/src/i18n/az/index.ts +++ b/shared/src/i18n/az/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -76,6 +77,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/az/notif.ts b/shared/src/i18n/az/notif.ts index de96728a7..72cae0bb6 100644 --- a/shared/src/i18n/az/notif.ts +++ b/shared/src/i18n/az/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Tətilə bax', 'notif.action.view_collection': 'Kolleksiyaya bax', 'notif.action.view_admin': 'İdarəetməyə keç', + 'notif.wanderer_source_hint.title': 'wanderer marşrutu', + 'notif.wanderer_source_hint.possible_change': '{trip} içində "{tour}" wanderer-də dəyişmiş ola bilər', + 'notif.wanderer_source_hint.changed': '{trip} içində "{tour}" wanderer-də dəyişdirildi', + 'notif.wanderer_source_hint.unverifiable': '{trip} içində "{tour}" artıq wanderer-də yoxlanıla bilmir', + 'notif.action.view_tour': 'Tura bax', 'notif.action.view': 'Bax', 'notif.action.accept': 'Qəbul et', 'notif.action.decline': 'İmtina et', diff --git a/shared/src/i18n/az/settings.ts b/shared/src/i18n/az/settings.ts index af2318aa0..98fdea145 100644 --- a/shared/src/i18n/az/settings.ts +++ b/shared/src/i18n/az/settings.ts @@ -134,6 +134,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Paylaşılan fotolar (Immich)', 'settings.notifyCollabMessage': 'Çat mesajları (Collab)', 'settings.notifyPackingTagged': 'Baqaj siyahısı: təyinatlar', + 'settings.notifyWandererSourceHint': 'wanderer: marşrutların barədə xəbərdarlıqlar', 'settings.notifyWebhook': 'Webhook bildirişləri', 'settings.notifyVersionAvailable': 'Yeni versiya əlçatandır', 'settings.notifyReplicaFailure': 'Yaddaş replikası xətası', diff --git a/shared/src/i18n/az/wandererWatch.ts b/shared/src/i18n/az/wandererWatch.ts new file mode 100644 index 000000000..7049367b5 --- /dev/null +++ b/shared/src/i18n/az/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Marşrutlarımdakı dəyişiklikləri yoxla', + 'wanderer.watch.hint': 'İdxal edilmiş marşrut dəyişmiş ola bilərsə bildiriş.', + 'wanderer.watch.info': + 'TREK idxal etdiyin hər marşrutun qısa qeydini gündə ən çox bir dəfə oxuyur, GPX faylını isə heç vaxt. O, yalnız bildirir: dəyişikliyin qəbul edilib-edilməməsinə turun mənbə yoxlaması ilə özün qərar verirsən.', + 'wanderer.watch.notConnected': 'Əvvəlcə wanderer qoş', + 'wanderer.watch.on': 'Fon yoxlaması açıldı', + 'wanderer.watch.off': 'Fon yoxlaması bağlandı', + 'wanderer.watch.failed': 'Fon yoxlamasını dəyişmək alınmadı', + 'wanderer.watch.lastRun': 'Son yoxlama {date}', + 'wanderer.watch.neverRun': 'Hələ yoxlanmayıb', + 'wanderer.watch.paused': '{time} vaxtınadək dayandırılıb', + 'wanderer.watch.pausedHint': + 'wanderer cavab vermədi. Hər uğursuzluqdan sonra TREK yenidən soruşmazdan əvvəl daha uzun gözləyir.', + 'wanderer.watch.blocked': 'Token rədd edildi', + 'wanderer.watch.blockedHint': + 'wanderer API tokenini rədd etdi. Bağlantı testi keçəndə və ya yeni token saxlananda yoxlama davam edir.', + 'wanderer.watch.hints': 'Xəbərdarlıqlar', + 'wanderer.watch.hintsCount': '{count} açıq xəbərdarlıq', + 'wanderer.watch.hintsCount.one': '{count} açıq xəbərdarlıq', + 'wanderer.watch.empty': 'Açıq xəbərdarlıq yoxdur', + 'wanderer.watch.dialogTitle': 'Marşrutların barədə xəbərdarlıqlar', + 'wanderer.watch.openTour': 'Turu aç', + 'wanderer.watch.checkedAt': 'Yoxlanıb {date}', + 'wanderer.watch.noticeLabel': 'Fon yoxlaması', + 'wanderer.watch.state.possible_change': 'Dəyişmiş ola bilər', + 'wanderer.watch.state.changed': 'Dəyişib', + 'wanderer.watch.state.unverifiable': 'Yoxlanıla bilmir', + 'wanderer.watch.stateHint.possible_change': + 'wanderer marşrutun vaxt möhürünü dəyişdi. Məzmunun fərqli olub-olmadığını yalnız mənbə yoxlaması göstərir.', + 'wanderer.watch.stateHint.changed': + 'wanderer qeydi başqa ad və ya təsvir göstərir. Mənbə yoxlaması dəyişən hər şeyi göstərir.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK bu marşrutu qiymətləndirə bilmədi, ona görə də dəyişiklik barədə heç nə deyilmir.', + 'wanderer.watch.reason.unavailable': 'Marşruta çatmaq olmur', + 'wanderer.watch.reason.no_change_time': 'Vaxt möhürü yoxdur', +}; +export default wandererWatch; diff --git a/shared/src/i18n/br/externalNotifications.ts b/shared/src/i18n/br/externalNotifications.ts index f84a1e820..353cdb916 100644 --- a/shared/src/i18n/br/externalNotifications.ts +++ b/shared/src/i18n/br/externalNotifications.ts @@ -78,6 +78,15 @@ const br: NotificationLocale = { title: 'Sessão Synology encerrada', body: 'Sua conta ou URL do Synology foi alterada. Você foi desconectado do Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Trilha do wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `“${p.tour}” em “${p.trip}” foi alterada no wanderer. Abra o passeio e verifique a fonte para ver todas as mudanças. Nada na viagem muda sozinho.` + : p.state === 'unverifiable' + ? `“${p.tour}” em “${p.trip}” não pode mais ser verificada no wanderer. O passeio da sua viagem continua como está.` + : `“${p.tour}” em “${p.trip}” pode ter mudado no wanderer. Abra o passeio e verifique a fonte para saber. Nada na viagem muda sozinho.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/br/index.ts b/shared/src/i18n/br/index.ts index 0a4677de3..3acc5e18e 100644 --- a/shared/src/i18n/br/index.ts +++ b/shared/src/i18n/br/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/br/notif.ts b/shared/src/i18n/br/notif.ts index 90037962c..21e4f9a83 100644 --- a/shared/src/i18n/br/notif.ts +++ b/shared/src/i18n/br/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Ver Vacay', 'notif.action.view_collection': 'Ver coleção', 'notif.action.view_admin': 'Ir para admin', + 'notif.wanderer_source_hint.title': 'Trilha do wanderer', + 'notif.wanderer_source_hint.possible_change': '“{tour}” em {trip} pode ter mudado no wanderer', + 'notif.wanderer_source_hint.changed': '“{tour}” em {trip} foi alterada no wanderer', + 'notif.wanderer_source_hint.unverifiable': '“{tour}” em {trip} não pode mais ser verificada no wanderer', + 'notif.action.view_tour': 'Ver passeio', 'notif.action.view': 'Ver', 'notif.action.accept': 'Aceitar', 'notif.action.decline': 'Recusar', diff --git a/shared/src/i18n/br/settings.ts b/shared/src/i18n/br/settings.ts index 1b0b4a41d..feb834239 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -105,6 +105,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Fotos compartilhadas (Immich)', 'settings.notifyCollabMessage': 'Mensagens de chat (Colab)', 'settings.notifyPackingTagged': 'Lista de mala: atribuições', + 'settings.notifyWandererSourceHint': 'wanderer: avisos sobre suas trilhas', 'settings.notifyWebhook': 'Notificações webhook', 'settings.notificationsDisabled': 'As notificações não estão configuradas. Peça a um administrador para ativar notificações por e-mail ou webhook.', diff --git a/shared/src/i18n/br/wandererWatch.ts b/shared/src/i18n/br/wandererWatch.ts new file mode 100644 index 000000000..c1f7769f6 --- /dev/null +++ b/shared/src/i18n/br/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Verificar mudanças nas minhas trilhas', + 'wanderer.watch.hint': 'Uma notificação quando uma trilha importada pode ter mudado.', + 'wanderer.watch.info': + 'No máximo uma vez por dia, o TREK lê o registro curto de cada trilha que você importou, nunca o arquivo GPX. Ele só avisa: incorporar uma mudança é decisão sua, pela verificação da fonte do passeio.', + 'wanderer.watch.notConnected': 'Conecte o wanderer primeiro', + 'wanderer.watch.on': 'Verificação em segundo plano ativada', + 'wanderer.watch.off': 'Verificação em segundo plano desativada', + 'wanderer.watch.failed': 'Não foi possível alterar a verificação em segundo plano', + 'wanderer.watch.lastRun': 'Última verificação {date}', + 'wanderer.watch.neverRun': 'Ainda não verificado', + 'wanderer.watch.paused': 'Pausado até {time}', + 'wanderer.watch.pausedHint': + 'O wanderer não respondeu. Depois de cada falha, o TREK espera mais antes de perguntar de novo.', + 'wanderer.watch.blocked': 'Token recusado', + 'wanderer.watch.blockedHint': + 'O wanderer recusou o token da API. A verificação volta quando um teste de conexão passar ou um novo token for salvo.', + 'wanderer.watch.hints': 'Avisos', + 'wanderer.watch.hintsCount': '{count} avisos abertos', + 'wanderer.watch.hintsCount.one': '{count} aviso aberto', + 'wanderer.watch.empty': 'Nenhum aviso aberto', + 'wanderer.watch.dialogTitle': 'Avisos sobre suas trilhas', + 'wanderer.watch.openTour': 'Abrir o passeio', + 'wanderer.watch.checkedAt': 'Verificado {date}', + 'wanderer.watch.noticeLabel': 'Verificação em segundo plano', + 'wanderer.watch.state.possible_change': 'Pode ter mudado', + 'wanderer.watch.state.changed': 'Alterado', + 'wanderer.watch.state.unverifiable': 'Não verificável', + 'wanderer.watch.stateHint.possible_change': + 'O wanderer mudou o carimbo de data da trilha. Só a verificação da fonte mostra se o conteúdo é diferente.', + 'wanderer.watch.stateHint.changed': + 'O registro do wanderer mostra outro nome ou outra descrição. A verificação da fonte mostra tudo o que mudou.', + 'wanderer.watch.stateHint.unverifiable': + 'O TREK não conseguiu avaliar esta trilha, então nada é dito sobre se ela mudou.', + 'wanderer.watch.reason.unavailable': 'Trilha inacessível', + 'wanderer.watch.reason.no_change_time': 'Sem carimbo de data', +}; +export default wandererWatch; diff --git a/shared/src/i18n/ca/externalNotifications.ts b/shared/src/i18n/ca/externalNotifications.ts index 38b576add..67cfc72e6 100644 --- a/shared/src/i18n/ca/externalNotifications.ts +++ b/shared/src/i18n/ca/externalNotifications.ts @@ -75,6 +75,15 @@ const ca: NotificationLocale = { title: 'Invitació a una col·lecció', body: `${p.actor} t'ha convidat a compartir una col·lecció. Obre TREK per acceptar-la o rebutjar-la.`, }), + wanderer_source_hint: (p) => ({ + title: `Camí de wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» a «${p.trip}» s'ha canviat a wanderer. Obre el recorregut i comprova'n la font per veure tots els canvis. Res del viatge canvia sol.` + : p.state === 'unverifiable' + ? `«${p.tour}» a «${p.trip}» ja no es pot comprovar a wanderer. El recorregut del teu viatge es queda com està.` + : `«${p.tour}» a «${p.trip}» pot haver canviat a wanderer. Obre el recorregut i comprova'n la font per saber-ho. Res del viatge canvia sol.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/ca/index.ts b/shared/src/i18n/ca/index.ts index 3bea06a32..3a2946cb3 100644 --- a/shared/src/i18n/ca/index.ts +++ b/shared/src/i18n/ca/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ca/notif.ts b/shared/src/i18n/ca/notif.ts index 73d473421..bb7f894bb 100644 --- a/shared/src/i18n/ca/notif.ts +++ b/shared/src/i18n/ca/notif.ts @@ -38,6 +38,11 @@ const notif: TranslationStrings = { 'notif.action.view_photos': 'Mostra les fotos', 'notif.action.view_vacay': 'Mostra Vacay', 'notif.action.view_admin': "Vés a l'administració", + 'notif.wanderer_source_hint.title': 'Camí de wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» a {trip} pot haver canviat a wanderer', + 'notif.wanderer_source_hint.changed': "«{tour}» a {trip} s'ha canviat a wanderer", + 'notif.wanderer_source_hint.unverifiable': '«{tour}» a {trip} ja no es pot comprovar a wanderer', + 'notif.action.view_tour': 'Mostra el recorregut', 'notif.action.view': 'Mostra', 'notif.action.accept': 'Accepta', 'notif.action.decline': 'Rebutja', diff --git a/shared/src/i18n/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 9f48850a6..09d23059e 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -87,6 +87,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Fotos compartides (Immich)', 'settings.notifyCollabMessage': 'Missatges de xat (Col·laboració)', 'settings.notifyPackingTagged': "Llista d'equipatge: assignacions", + 'settings.notifyWandererSourceHint': 'wanderer: avisos sobre els teus camins', 'settings.notifyWebhook': 'Notificacions webhook', 'settings.notificationsDisabled': 'Les notificacions no estan configurades. Demana a un administrador que activi les notificacions per correu o webhook.', diff --git a/shared/src/i18n/ca/wandererWatch.ts b/shared/src/i18n/ca/wandererWatch.ts new file mode 100644 index 000000000..e87256083 --- /dev/null +++ b/shared/src/i18n/ca/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Comprovar canvis en els meus camins', + 'wanderer.watch.hint': 'Un avís quan un camí importat podria haver canviat.', + 'wanderer.watch.info': + "Com a molt un cop al dia, TREK llegeix la fitxa breu de cada camí que has importat, mai el seu fitxer GPX. Només t'avisa: incorporar un canvi ho decideixes tu, amb la comprovació de la font del recorregut.", + 'wanderer.watch.notConnected': 'Connecta primer wanderer', + 'wanderer.watch.on': 'Comprovació en segon pla activada', + 'wanderer.watch.off': 'Comprovació en segon pla desactivada', + 'wanderer.watch.failed': "No s'ha pogut canviar la comprovació en segon pla", + 'wanderer.watch.lastRun': 'Darrera comprovació {date}', + 'wanderer.watch.neverRun': 'Encara sense comprovar', + 'wanderer.watch.paused': 'En pausa fins a {time}', + 'wanderer.watch.pausedHint': + 'wanderer no ha respost. Després de cada error, TREK espera més abans de tornar a preguntar.', + 'wanderer.watch.blocked': 'Testimoni rebutjat', + 'wanderer.watch.blockedHint': + "wanderer ha rebutjat el testimoni de l'API. La comprovació es reprèn quan una prova de connexió funcioni o es desi un testimoni nou.", + 'wanderer.watch.hints': 'Avisos', + 'wanderer.watch.hintsCount': '{count} avisos oberts', + 'wanderer.watch.hintsCount.one': '{count} avís obert', + 'wanderer.watch.empty': 'Cap avís obert', + 'wanderer.watch.dialogTitle': 'Avisos sobre els teus camins', + 'wanderer.watch.openTour': 'Obre el recorregut', + 'wanderer.watch.checkedAt': 'Comprovat {date}', + 'wanderer.watch.noticeLabel': 'Comprovació en segon pla', + 'wanderer.watch.state.possible_change': 'Pot haver canviat', + 'wanderer.watch.state.changed': 'Canviat', + 'wanderer.watch.state.unverifiable': 'No comprovable', + 'wanderer.watch.stateHint.possible_change': + 'wanderer ha canviat la marca de temps del camí. Només la comprovació de la font mostra si el contingut és diferent.', + 'wanderer.watch.stateHint.changed': + 'La fitxa de wanderer mostra un altre nom o una altra descripció. La comprovació de la font mostra tot el que ha canviat.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK no ha pogut valorar aquest camí, així que no es diu res sobre si ha canviat.', + 'wanderer.watch.reason.unavailable': 'Camí inaccessible', + 'wanderer.watch.reason.no_change_time': 'Sense marca de temps', +}; +export default wandererWatch; diff --git a/shared/src/i18n/cs/externalNotifications.ts b/shared/src/i18n/cs/externalNotifications.ts index ff4f07630..fd923b658 100644 --- a/shared/src/i18n/cs/externalNotifications.ts +++ b/shared/src/i18n/cs/externalNotifications.ts @@ -81,6 +81,15 @@ const cs: NotificationLocale = { title: 'Relace Synology byla zrušena', body: 'Váš účet nebo URL Synology se změnil. Byli jste odhlášeni ze Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Trasa wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}“ v „${p.trip}“ byla ve wanderer změněna. Otevřete túru a zkontrolujte její zdroj, abyste viděli všechny změny. V cestě se nic nezmění samo.` + : p.state === 'unverifiable' + ? `„${p.tour}“ v „${p.trip}“ už nelze ve wanderer ověřit. Túra ve vaší cestě zůstává, jak je.` + : `„${p.tour}“ v „${p.trip}“ se ve wanderer možná změnila. Otevřete túru a zkontrolujte její zdroj. V cestě se nic nezmění samo.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/cs/index.ts b/shared/src/i18n/cs/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/cs/index.ts +++ b/shared/src/i18n/cs/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/cs/notif.ts b/shared/src/i18n/cs/notif.ts index 151d33ca8..8ee8c5d18 100644 --- a/shared/src/i18n/cs/notif.ts +++ b/shared/src/i18n/cs/notif.ts @@ -42,6 +42,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Zobrazit Vacay', 'notif.action.view_collection': 'Zobrazit sbírku', 'notif.action.view_admin': 'Jít do adminu', + 'notif.wanderer_source_hint.title': 'Trasa wanderer', + 'notif.wanderer_source_hint.possible_change': '„{tour}“ v {trip} se ve wanderer možná změnila', + 'notif.wanderer_source_hint.changed': '„{tour}“ v {trip} byla ve wanderer změněna', + 'notif.wanderer_source_hint.unverifiable': '„{tour}“ v {trip} už nelze ve wanderer ověřit', + 'notif.action.view_tour': 'Zobrazit túru', 'notif.action.view': 'Zobrazit', 'notif.action.accept': 'Přijmout', 'notif.action.decline': 'Odmítnout', diff --git a/shared/src/i18n/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 2d3d2f160..a9473be04 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -101,6 +101,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Sdílené fotky (Immich)', 'settings.notifyCollabMessage': 'Zprávy v chatu (Collab)', 'settings.notifyPackingTagged': 'Seznam balení: přiřazení', + 'settings.notifyWandererSourceHint': 'wanderer: upozornění k vašim trasám', 'settings.notifyWebhook': 'Webhook oznámení', 'settings.notificationsDisabled': 'Oznámení nejsou nakonfigurována. Požádejte správce o aktivaci e-mailových nebo webhookových oznámení.', diff --git a/shared/src/i18n/cs/wandererWatch.ts b/shared/src/i18n/cs/wandererWatch.ts new file mode 100644 index 000000000..40fe98997 --- /dev/null +++ b/shared/src/i18n/cs/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Kontrolovat změny mých tras', + 'wanderer.watch.hint': 'Upozornění, když se importovaná trasa mohla změnit.', + 'wanderer.watch.info': + 'Nejvýše jednou denně TREK přečte krátký záznam každé trasy, kterou jste importovali, nikdy její soubor GPX. Jen vás upozorní: zda změnu převezmete, rozhodnete sami kontrolou zdroje túry.', + 'wanderer.watch.notConnected': 'Nejdřív připojte wanderer', + 'wanderer.watch.on': 'Kontrola na pozadí zapnuta', + 'wanderer.watch.off': 'Kontrola na pozadí vypnuta', + 'wanderer.watch.failed': 'Kontrolu na pozadí se nepodařilo přepnout', + 'wanderer.watch.lastRun': 'Naposledy zkontrolováno {date}', + 'wanderer.watch.neverRun': 'Zatím nezkontrolováno', + 'wanderer.watch.paused': 'Pozastaveno do {time}', + 'wanderer.watch.pausedHint': 'wanderer neodpověděl. Po každém nezdaru TREK čeká déle, než se zeptá znovu.', + 'wanderer.watch.blocked': 'Token odmítnut', + 'wanderer.watch.blockedHint': + 'wanderer odmítl token API. Kontrola pokračuje, jakmile projde test připojení nebo se uloží nový token.', + 'wanderer.watch.hints': 'Upozornění', + 'wanderer.watch.hintsCount': '{count} otevřených upozornění', + 'wanderer.watch.hintsCount.one': '{count} otevřené upozornění', + 'wanderer.watch.hintsCount.few': '{count} otevřená upozornění', + 'wanderer.watch.empty': 'Žádná otevřená upozornění', + 'wanderer.watch.dialogTitle': 'Upozornění k vašim trasám', + 'wanderer.watch.openTour': 'Otevřít túru', + 'wanderer.watch.checkedAt': 'Zkontrolováno {date}', + 'wanderer.watch.noticeLabel': 'Kontrola na pozadí', + 'wanderer.watch.state.possible_change': 'Možná změněno', + 'wanderer.watch.state.changed': 'Změněno', + 'wanderer.watch.state.unverifiable': 'Nelze ověřit', + 'wanderer.watch.stateHint.possible_change': + 'wanderer změnil časové razítko trasy. Jen kontrola zdroje ukáže, zda se liší obsah.', + 'wanderer.watch.stateHint.changed': + 'Záznam ve wanderer ukazuje jiný název nebo popis. Kontrola zdroje ukáže vše, co se změnilo.', + 'wanderer.watch.stateHint.unverifiable': 'TREK tuto trasu nemohl posoudit, takže se o změně nic neříká.', + 'wanderer.watch.reason.unavailable': 'Trasa není dostupná', + 'wanderer.watch.reason.no_change_time': 'Bez časového razítka', +}; +export default wandererWatch; diff --git a/shared/src/i18n/de/externalNotifications.ts b/shared/src/i18n/de/externalNotifications.ts index 7b61e842a..b063e75f6 100644 --- a/shared/src/i18n/de/externalNotifications.ts +++ b/shared/src/i18n/de/externalNotifications.ts @@ -75,6 +75,15 @@ const de: NotificationLocale = { title: 'Synology-Sitzung beendet', body: 'Dein Synology-Konto oder die URL hat sich geändert. Du wurdest von Synology Photos abgemeldet.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer-Trail: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}“ in „${p.trip}“ wurde in wanderer geändert. Öffne die Tour und prüfe ihre Quelle, um alle Änderungen zu sehen. In der Reise ändert sich nichts von selbst.` + : p.state === 'unverifiable' + ? `„${p.tour}“ in „${p.trip}“ lässt sich in wanderer nicht mehr prüfen. Die Tour in deiner Reise bleibt, wie sie ist.` + : `„${p.tour}“ in „${p.trip}“ hat sich in wanderer möglicherweise geändert. Öffne die Tour und prüfe ihre Quelle, um es zu sehen. In der Reise ändert sich nichts von selbst.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/de/index.ts b/shared/src/i18n/de/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/de/index.ts +++ b/shared/src/i18n/de/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/de/notif.ts b/shared/src/i18n/de/notif.ts index ef91c87b7..24f229883 100644 --- a/shared/src/i18n/de/notif.ts +++ b/shared/src/i18n/de/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vacay ansehen', 'notif.action.view_collection': 'Sammlung ansehen', 'notif.action.view_admin': 'Zum Admin', + 'notif.wanderer_source_hint.title': 'wanderer-Trail', + 'notif.wanderer_source_hint.possible_change': '„{tour}“ in {trip} hat sich in wanderer möglicherweise geändert', + 'notif.wanderer_source_hint.changed': '„{tour}“ in {trip} wurde in wanderer geändert', + 'notif.wanderer_source_hint.unverifiable': '„{tour}“ in {trip} lässt sich in wanderer nicht mehr prüfen', + 'notif.action.view_tour': 'Tour ansehen', 'notif.action.view': 'Ansehen', 'notif.action.accept': 'Annehmen', 'notif.action.decline': 'Ablehnen', diff --git a/shared/src/i18n/de/settings.ts b/shared/src/i18n/de/settings.ts index a7e116512..b67a603f5 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -105,6 +105,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Geteilte Fotos (Immich)', 'settings.notifyCollabMessage': 'Chat-Nachrichten (Collab)', 'settings.notifyPackingTagged': 'Packliste: Zuweisungen', + 'settings.notifyWandererSourceHint': 'wanderer: Hinweise zu deinen Trails', 'settings.notifyWebhook': 'Webhook-Benachrichtigungen', 'settings.notificationsDisabled': 'Benachrichtigungen sind nicht konfiguriert. Bitten Sie einen Administrator, E-Mail- oder Webhook-Benachrichtungen zu aktivieren.', diff --git a/shared/src/i18n/de/wandererWatch.ts b/shared/src/i18n/de/wandererWatch.ts new file mode 100644 index 000000000..abbc03717 --- /dev/null +++ b/shared/src/i18n/de/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Meine Trails auf Änderungen prüfen', + 'wanderer.watch.hint': 'Eine Benachrichtigung, wenn sich ein importierter Trail geändert haben könnte.', + 'wanderer.watch.info': + 'Höchstens einmal am Tag liest TREK den kurzen Eintrag jedes importierten Trails, nie seine GPX-Datei. Es benachrichtigt dich nur: Ob eine Änderung übernommen wird, entscheidest du selbst über die Quellenprüfung der Tour.', + 'wanderer.watch.notConnected': 'Verbinde zuerst wanderer', + 'wanderer.watch.on': 'Hintergrundprüfung eingeschaltet', + 'wanderer.watch.off': 'Hintergrundprüfung ausgeschaltet', + 'wanderer.watch.failed': 'Die Hintergrundprüfung konnte nicht umgeschaltet werden', + 'wanderer.watch.lastRun': 'Zuletzt geprüft {date}', + 'wanderer.watch.neverRun': 'Noch nicht geprüft', + 'wanderer.watch.paused': 'Pausiert bis {time}', + 'wanderer.watch.pausedHint': + 'wanderer hat nicht geantwortet. Nach jedem Fehlschlag wartet TREK länger, bevor es wieder fragt.', + 'wanderer.watch.blocked': 'Token abgelehnt', + 'wanderer.watch.blockedHint': + 'wanderer hat das API-Token abgelehnt. Die Prüfung läuft weiter, sobald ein Verbindungstest gelingt oder ein neues Token gespeichert ist.', + 'wanderer.watch.hints': 'Hinweise', + 'wanderer.watch.hintsCount': '{count} offene Hinweise', + 'wanderer.watch.hintsCount.one': '{count} offener Hinweis', + 'wanderer.watch.empty': 'Keine offenen Hinweise', + 'wanderer.watch.dialogTitle': 'Hinweise zu deinen Trails', + 'wanderer.watch.openTour': 'Tour öffnen', + 'wanderer.watch.checkedAt': 'Geprüft {date}', + 'wanderer.watch.noticeLabel': 'Hintergrundprüfung', + 'wanderer.watch.state.possible_change': 'Möglicherweise geändert', + 'wanderer.watch.state.changed': 'Geändert', + 'wanderer.watch.state.unverifiable': 'Nicht prüfbar', + 'wanderer.watch.stateHint.possible_change': + 'wanderer hat den Zeitstempel des Trails geändert. Ob sich der Inhalt unterscheidet, zeigt erst die Quellenprüfung.', + 'wanderer.watch.stateHint.changed': + 'Der Eintrag in wanderer hat einen anderen Namen oder eine andere Beschreibung. Was sich alles geändert hat, zeigt die Quellenprüfung.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK konnte diesen Trail nicht beurteilen. Ob er sich geändert hat, bleibt offen.', + 'wanderer.watch.reason.unavailable': 'Trail nicht erreichbar', + 'wanderer.watch.reason.no_change_time': 'Kein Zeitstempel', +}; +export default wandererWatch; diff --git a/shared/src/i18n/en/externalNotifications.ts b/shared/src/i18n/en/externalNotifications.ts index cc51f83eb..b2a84c04d 100644 --- a/shared/src/i18n/en/externalNotifications.ts +++ b/shared/src/i18n/en/externalNotifications.ts @@ -75,6 +75,15 @@ const en: NotificationLocale = { title: 'Synology session cleared', body: 'Your Synology account or URL changed. You have been logged out of Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer trail: ${p.tour}`, + body: + p.state === 'changed' + ? `"${p.tour}" in "${p.trip}" was changed in wanderer. Open the tour and check its source to see everything that changed. Nothing in the trip changes by itself.` + : p.state === 'unverifiable' + ? `"${p.tour}" in "${p.trip}" can no longer be checked in wanderer. The tour in your trip stays as it is.` + : `"${p.tour}" in "${p.trip}" may have changed in wanderer. Open the tour and check its source to see whether it did. Nothing in the trip changes by itself.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/en/index.ts b/shared/src/i18n/en/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/en/index.ts +++ b/shared/src/i18n/en/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/en/notif.ts b/shared/src/i18n/en/notif.ts index 8ccc82ff1..237f1e027 100644 --- a/shared/src/i18n/en/notif.ts +++ b/shared/src/i18n/en/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'View Vacay', 'notif.action.view_collection': 'View Collection', 'notif.action.view_admin': 'Go to Admin', + 'notif.wanderer_source_hint.title': 'wanderer trail', + 'notif.wanderer_source_hint.possible_change': '“{tour}” in {trip} may have changed in wanderer', + 'notif.wanderer_source_hint.changed': '“{tour}” in {trip} was changed in wanderer', + 'notif.wanderer_source_hint.unverifiable': '“{tour}” in {trip} can no longer be checked in wanderer', + 'notif.action.view_tour': 'View Tour', 'notif.action.view': 'View', 'notif.action.accept': 'Accept', 'notif.action.decline': 'Decline', diff --git a/shared/src/i18n/en/settings.ts b/shared/src/i18n/en/settings.ts index f9a1e80c5..6427820e5 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -115,6 +115,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Shared photos (Immich)', 'settings.notifyCollabMessage': 'Chat messages (Collab)', 'settings.notifyPackingTagged': 'Packing list: assignments', + 'settings.notifyWandererSourceHint': 'wanderer: hints on your trails', 'settings.notifyWebhook': 'Webhook notifications', 'settings.notifyVersionAvailable': 'New version available', 'settings.notifyReplicaFailure': 'Storage replica failure', diff --git a/shared/src/i18n/en/wandererWatch.ts b/shared/src/i18n/en/wandererWatch.ts new file mode 100644 index 000000000..881c74c7d --- /dev/null +++ b/shared/src/i18n/en/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Check my trails for changes', + 'wanderer.watch.hint': 'A notification when an imported trail may have changed.', + 'wanderer.watch.info': + 'At most once a day, TREK reads the short record of each trail you imported, never its GPX file. It only notifies you: whether a change comes in is up to you, through the source check of the tour.', + 'wanderer.watch.notConnected': 'Connect wanderer first', + 'wanderer.watch.on': 'Background check switched on', + 'wanderer.watch.off': 'Background check switched off', + 'wanderer.watch.failed': 'The background check could not be switched', + 'wanderer.watch.lastRun': 'Last looked {date}', + 'wanderer.watch.neverRun': 'Not looked yet', + 'wanderer.watch.paused': 'Paused until {time}', + 'wanderer.watch.pausedHint': 'wanderer did not answer. TREK waits longer after each failure before it asks again.', + 'wanderer.watch.blocked': 'Token refused', + 'wanderer.watch.blockedHint': + 'wanderer refused the API token. The check resumes once a connection test passes or a new token is saved.', + 'wanderer.watch.hints': 'Hints', + 'wanderer.watch.hintsCount': '{count} open hints', + 'wanderer.watch.hintsCount.one': '{count} open hint', + 'wanderer.watch.empty': 'No open hints', + 'wanderer.watch.dialogTitle': 'Hints on your trails', + 'wanderer.watch.openTour': 'Open the tour', + 'wanderer.watch.checkedAt': 'Looked {date}', + 'wanderer.watch.noticeLabel': 'Background check', + 'wanderer.watch.state.possible_change': 'May have changed', + 'wanderer.watch.state.changed': 'Changed', + 'wanderer.watch.state.unverifiable': 'Not checkable', + 'wanderer.watch.stateHint.possible_change': + "wanderer changed the trail's time stamp. Only the source check shows whether its content differs.", + 'wanderer.watch.stateHint.changed': + "wanderer's record shows another name or description. The source check shows everything that changed.", + 'wanderer.watch.stateHint.unverifiable': + 'TREK could not judge this trail, so nothing is said about whether it changed.', + 'wanderer.watch.reason.unavailable': 'Trail out of reach', + 'wanderer.watch.reason.no_change_time': 'No time stamp', +}; +export default wandererWatch; diff --git a/shared/src/i18n/es/externalNotifications.ts b/shared/src/i18n/es/externalNotifications.ts index 84fad9936..28296d588 100644 --- a/shared/src/i18n/es/externalNotifications.ts +++ b/shared/src/i18n/es/externalNotifications.ts @@ -75,6 +75,15 @@ const es: NotificationLocale = { title: 'Sesión de Synology cerrada', body: 'Tu cuenta o URL de Synology ha cambiado. Has cerrado sesión en Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Ruta de wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» en «${p.trip}» se cambió en wanderer. Abre el recorrido y comprueba su fuente para ver todos los cambios. Nada del viaje cambia por sí solo.` + : p.state === 'unverifiable' + ? `«${p.tour}» en «${p.trip}» ya no se puede comprobar en wanderer. El recorrido de tu viaje se queda como está.` + : `«${p.tour}» en «${p.trip}» puede haber cambiado en wanderer. Abre el recorrido y comprueba su fuente para saberlo. Nada del viaje cambia por sí solo.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/es/index.ts b/shared/src/i18n/es/index.ts index 055ff770f..c2fb5ca5b 100644 --- a/shared/src/i18n/es/index.ts +++ b/shared/src/i18n/es/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/es/notif.ts b/shared/src/i18n/es/notif.ts index 566186ef0..60afce7ef 100644 --- a/shared/src/i18n/es/notif.ts +++ b/shared/src/i18n/es/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Ver Vacay', 'notif.action.view_collection': 'Ver colección', 'notif.action.view_admin': 'Ir al admin', + 'notif.wanderer_source_hint.title': 'Ruta de wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» en {trip} puede haber cambiado en wanderer', + 'notif.wanderer_source_hint.changed': '«{tour}» en {trip} se cambió en wanderer', + 'notif.wanderer_source_hint.unverifiable': '«{tour}» en {trip} ya no se puede comprobar en wanderer', + 'notif.action.view_tour': 'Ver recorrido', 'notif.action.view': 'Ver', 'notif.action.accept': 'Aceptar', 'notif.action.decline': 'Rechazar', diff --git a/shared/src/i18n/es/settings.ts b/shared/src/i18n/es/settings.ts index 8ac3e0218..5a783706c 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -104,6 +104,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Fotos compartidas (Immich)', 'settings.notifyCollabMessage': 'Mensajes de chat (Collab)', 'settings.notifyPackingTagged': 'Lista de equipaje: asignaciones', + 'settings.notifyWandererSourceHint': 'wanderer: avisos sobre tus rutas', 'settings.notifyWebhook': 'Notificaciones webhook', 'settings.notificationsDisabled': 'Las notificaciones no están configuradas. Pida a un administrador que active las notificaciones por correo o webhook.', diff --git a/shared/src/i18n/es/wandererWatch.ts b/shared/src/i18n/es/wandererWatch.ts new file mode 100644 index 000000000..5f9005f22 --- /dev/null +++ b/shared/src/i18n/es/wandererWatch.ts @@ -0,0 +1,38 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Comprobar cambios en mis rutas', + 'wanderer.watch.hint': 'Un aviso cuando una ruta importada podría haber cambiado.', + 'wanderer.watch.info': + 'Como mucho una vez al día, TREK lee la ficha breve de cada ruta que importaste, nunca su archivo GPX. Solo te avisa: tú decides si incorporas un cambio, mediante la comprobación de la fuente del recorrido.', + 'wanderer.watch.notConnected': 'Conecta primero wanderer', + 'wanderer.watch.on': 'Comprobación en segundo plano activada', + 'wanderer.watch.off': 'Comprobación en segundo plano desactivada', + 'wanderer.watch.failed': 'No se pudo cambiar la comprobación en segundo plano', + 'wanderer.watch.lastRun': 'Última comprobación {date}', + 'wanderer.watch.neverRun': 'Aún sin comprobar', + 'wanderer.watch.paused': 'En pausa hasta {time}', + 'wanderer.watch.pausedHint': 'wanderer no respondió. Tras cada fallo, TREK espera más antes de volver a preguntar.', + 'wanderer.watch.blocked': 'Token rechazado', + 'wanderer.watch.blockedHint': + 'wanderer rechazó el token de la API. La comprobación se reanuda cuando una prueba de conexión funcione o se guarde un token nuevo.', + 'wanderer.watch.hints': 'Avisos', + 'wanderer.watch.hintsCount': '{count} avisos abiertos', + 'wanderer.watch.hintsCount.one': '{count} aviso abierto', + 'wanderer.watch.empty': 'No hay avisos abiertos', + 'wanderer.watch.dialogTitle': 'Avisos sobre tus rutas', + 'wanderer.watch.openTour': 'Abrir el recorrido', + 'wanderer.watch.checkedAt': 'Comprobado {date}', + 'wanderer.watch.noticeLabel': 'Comprobación en segundo plano', + 'wanderer.watch.state.possible_change': 'Puede haber cambiado', + 'wanderer.watch.state.changed': 'Cambiado', + 'wanderer.watch.state.unverifiable': 'No comprobable', + 'wanderer.watch.stateHint.possible_change': + 'wanderer cambió la marca de tiempo de la ruta. Solo la comprobación de la fuente muestra si su contenido es distinto.', + 'wanderer.watch.stateHint.changed': + 'La ficha de wanderer muestra otro nombre u otra descripción. La comprobación de la fuente muestra todo lo que cambió.', + 'wanderer.watch.stateHint.unverifiable': 'TREK no pudo valorar esta ruta, así que no se dice nada sobre si cambió.', + 'wanderer.watch.reason.unavailable': 'Ruta no accesible', + 'wanderer.watch.reason.no_change_time': 'Sin marca de tiempo', +}; +export default wandererWatch; diff --git a/shared/src/i18n/et/externalNotifications.ts b/shared/src/i18n/et/externalNotifications.ts index 4abde3b0d..1ec2c9e12 100644 --- a/shared/src/i18n/et/externalNotifications.ts +++ b/shared/src/i18n/et/externalNotifications.ts @@ -76,6 +76,15 @@ const et: NotificationLocale = { title: 'Synology seanss tühjendatud', body: 'Sinu Synology konto või URL muutus. Sind on Synology Photosist välja logitud.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer rada: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}“ reisil „${p.trip}“ muudeti wandereris. Ava matk ja kontrolli selle allikat, et näha kõiki muudatusi. Reisis ei muutu midagi iseenesest.` + : p.state === 'unverifiable' + ? `„${p.tour}“ reisil „${p.trip}“ ei saa wandereris enam kontrollida. Matk sinu reisis jääb samaks.` + : `„${p.tour}“ reisil „${p.trip}“ võib olla wandereris muutunud. Ava matk ja kontrolli selle allikat. Reisis ei muutu midagi iseenesest.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/et/index.ts b/shared/src/i18n/et/index.ts index eacf3e31c..ccbf91396 100644 --- a/shared/src/i18n/et/index.ts +++ b/shared/src/i18n/et/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -76,6 +77,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/et/notif.ts b/shared/src/i18n/et/notif.ts index 08cf6d20b..269e2a06d 100644 --- a/shared/src/i18n/et/notif.ts +++ b/shared/src/i18n/et/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vaata Vacayd', 'notif.action.view_collection': 'Vaata kogumikku', 'notif.action.view_admin': 'Ava haldus', + 'notif.wanderer_source_hint.title': 'wanderer rada', + 'notif.wanderer_source_hint.possible_change': '„{tour}“ reisil {trip} võib olla wandereris muutunud', + 'notif.wanderer_source_hint.changed': '„{tour}“ reisil {trip} muudeti wandereris', + 'notif.wanderer_source_hint.unverifiable': '„{tour}“ reisil {trip} ei saa wandereris enam kontrollida', + 'notif.action.view_tour': 'Vaata matka', 'notif.action.view': 'Vaata', 'notif.action.accept': 'Nõustu', 'notif.action.decline': 'Keeldu', diff --git a/shared/src/i18n/et/settings.ts b/shared/src/i18n/et/settings.ts index 0c949264f..e7978b202 100644 --- a/shared/src/i18n/et/settings.ts +++ b/shared/src/i18n/et/settings.ts @@ -120,6 +120,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Jagatud fotod (Immich)', 'settings.notifyCollabMessage': 'Vestlussõnumid (koostöö)', 'settings.notifyPackingTagged': 'Pakkimisnimekiri: määrangud', + 'settings.notifyWandererSourceHint': 'wanderer: vihjed sinu radade kohta', 'settings.notifyWebhook': 'Veebihaagi teavitused', 'settings.notifyVersionAvailable': 'Uus versioon saadaval', 'settings.notifyReplicaFailure': 'Salvestuskoopia tõrge', diff --git a/shared/src/i18n/et/wandererWatch.ts b/shared/src/i18n/et/wandererWatch.ts new file mode 100644 index 000000000..5e08bed05 --- /dev/null +++ b/shared/src/i18n/et/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Kontrolli minu radade muudatusi', + 'wanderer.watch.hint': 'Teavitus, kui imporditud rada võib olla muutunud.', + 'wanderer.watch.info': + 'Kõige rohkem kord päevas loeb TREK iga imporditud raja lühikirjet, mitte kunagi selle GPX-faili. See ainult teavitab: kas muudatus üle võtta, otsustad ise matka allika kontrolliga.', + 'wanderer.watch.notConnected': 'Ühenda esmalt wanderer', + 'wanderer.watch.on': 'Taustakontroll on sisse lülitatud', + 'wanderer.watch.off': 'Taustakontroll on välja lülitatud', + 'wanderer.watch.failed': 'Taustakontrolli ei õnnestunud ümber lülitada', + 'wanderer.watch.lastRun': 'Viimati kontrollitud {date}', + 'wanderer.watch.neverRun': 'Veel kontrollimata', + 'wanderer.watch.paused': 'Peatatud kuni {time}', + 'wanderer.watch.pausedHint': + 'wanderer ei vastanud. Pärast iga ebaõnnestumist ootab TREK kauem, enne kui uuesti küsib.', + 'wanderer.watch.blocked': 'Tõend tagasi lükatud', + 'wanderer.watch.blockedHint': + 'wanderer lükkas API-tõendi tagasi. Kontroll jätkub, kui ühenduse test õnnestub või uus tõend salvestatakse.', + 'wanderer.watch.hints': 'Vihjed', + 'wanderer.watch.hintsCount': '{count} avatud vihjet', + 'wanderer.watch.hintsCount.one': '{count} avatud vihje', + 'wanderer.watch.empty': 'Avatud vihjeid pole', + 'wanderer.watch.dialogTitle': 'Vihjed sinu radade kohta', + 'wanderer.watch.openTour': 'Ava matk', + 'wanderer.watch.checkedAt': 'Kontrollitud {date}', + 'wanderer.watch.noticeLabel': 'Taustakontroll', + 'wanderer.watch.state.possible_change': 'Võib olla muutunud', + 'wanderer.watch.state.changed': 'Muudetud', + 'wanderer.watch.state.unverifiable': 'Ei saa kontrollida', + 'wanderer.watch.stateHint.possible_change': + 'wanderer muutis raja ajatemplit. Ainult allika kontroll näitab, kas sisu erineb.', + 'wanderer.watch.stateHint.changed': + 'wanderer kirje näitab teist nime või kirjeldust. Allika kontroll näitab kõike, mis muutus.', + 'wanderer.watch.stateHint.unverifiable': 'TREK ei saanud seda rada hinnata, seega muudatuse kohta ei öelda midagi.', + 'wanderer.watch.reason.unavailable': 'Rada pole kättesaadav', + 'wanderer.watch.reason.no_change_time': 'Ajatempel puudub', +}; +export default wandererWatch; diff --git a/shared/src/i18n/externalNotifications/types.ts b/shared/src/i18n/externalNotifications/types.ts index 8471ba8d5..fe8aed831 100644 --- a/shared/src/i18n/externalNotifications/types.ts +++ b/shared/src/i18n/externalNotifications/types.ts @@ -36,6 +36,7 @@ export type NotificationEventKey = | 'replica_failure' | 'paddle_region_requested' | 'synology_session_cleared' + | 'wanderer_source_hint' | 'plugin_notification'; export interface NotificationLocale { diff --git a/shared/src/i18n/fr/externalNotifications.ts b/shared/src/i18n/fr/externalNotifications.ts index 16bc2828a..42d812e5c 100644 --- a/shared/src/i18n/fr/externalNotifications.ts +++ b/shared/src/i18n/fr/externalNotifications.ts @@ -75,6 +75,15 @@ const fr: NotificationLocale = { title: 'Session Synology effacée', body: 'Votre compte ou URL Synology a changé. Vous avez été déconnecté de Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Sentier wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `« ${p.tour} » dans « ${p.trip} » a été modifié dans wanderer. Ouvrez le parcours et vérifiez sa source pour voir tous les changements. Rien ne change de lui-même dans le voyage.` + : p.state === 'unverifiable' + ? `« ${p.tour} » dans « ${p.trip} » ne peut plus être vérifié dans wanderer. Le parcours de votre voyage reste tel quel.` + : `« ${p.tour} » dans « ${p.trip} » a peut-être changé dans wanderer. Ouvrez le parcours et vérifiez sa source pour le savoir. Rien ne change de lui-même dans le voyage.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/fr/index.ts b/shared/src/i18n/fr/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/fr/index.ts +++ b/shared/src/i18n/fr/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/fr/notif.ts b/shared/src/i18n/fr/notif.ts index 1a4b1f4fb..d06c114f8 100644 --- a/shared/src/i18n/fr/notif.ts +++ b/shared/src/i18n/fr/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Voir Vacay', 'notif.action.view_collection': 'Voir la collection', 'notif.action.view_admin': "Aller à l'admin", + 'notif.wanderer_source_hint.title': 'Sentier wanderer', + 'notif.wanderer_source_hint.possible_change': '« {tour} » dans {trip} a peut-être changé dans wanderer', + 'notif.wanderer_source_hint.changed': '« {tour} » dans {trip} a été modifié dans wanderer', + 'notif.wanderer_source_hint.unverifiable': '« {tour} » dans {trip} ne peut plus être vérifié dans wanderer', + 'notif.action.view_tour': 'Voir le parcours', 'notif.action.view': 'Voir', 'notif.action.accept': 'Accepter', 'notif.action.decline': 'Refuser', diff --git a/shared/src/i18n/fr/settings.ts b/shared/src/i18n/fr/settings.ts index e23ea0d1a..f418df916 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -106,6 +106,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Photos partagées (Immich)', 'settings.notifyCollabMessage': 'Messages de chat (Collab)', 'settings.notifyPackingTagged': 'Liste de bagages : attributions', + 'settings.notifyWandererSourceHint': 'wanderer : signalements sur vos sentiers', 'settings.notifyWebhook': 'Notifications webhook', 'settings.notificationsDisabled': "Les notifications ne sont pas configurées. Demandez à un administrateur d'activer les notifications par e-mail ou webhook.", diff --git a/shared/src/i18n/fr/wandererWatch.ts b/shared/src/i18n/fr/wandererWatch.ts new file mode 100644 index 000000000..e2e49102c --- /dev/null +++ b/shared/src/i18n/fr/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Vérifier les changements de mes sentiers', + 'wanderer.watch.hint': 'Une notification quand un sentier importé a pu changer.', + 'wanderer.watch.info': + 'Au plus une fois par jour, TREK lit la courte fiche de chaque sentier que vous avez importé, jamais son fichier GPX. Il vous prévient seulement : reprendre un changement reste votre choix, via la vérification de la source du parcours.', + 'wanderer.watch.notConnected': "Connectez d'abord wanderer", + 'wanderer.watch.on': 'Vérification en arrière-plan activée', + 'wanderer.watch.off': 'Vérification en arrière-plan désactivée', + 'wanderer.watch.failed': "La vérification en arrière-plan n'a pas pu être modifiée", + 'wanderer.watch.lastRun': 'Dernière vérification {date}', + 'wanderer.watch.neverRun': 'Pas encore vérifié', + 'wanderer.watch.paused': "En pause jusqu'à {time}", + 'wanderer.watch.pausedHint': + "wanderer n'a pas répondu. Après chaque échec, TREK attend plus longtemps avant de redemander.", + 'wanderer.watch.blocked': 'Jeton refusé', + 'wanderer.watch.blockedHint': + "wanderer a refusé le jeton API. La vérification reprend dès qu'un test de connexion réussit ou qu'un nouveau jeton est enregistré.", + 'wanderer.watch.hints': 'Signalements', + 'wanderer.watch.hintsCount': '{count} signalements ouverts', + 'wanderer.watch.hintsCount.one': '{count} signalement ouvert', + 'wanderer.watch.empty': 'Aucun signalement ouvert', + 'wanderer.watch.dialogTitle': 'Signalements sur vos sentiers', + 'wanderer.watch.openTour': 'Ouvrir le parcours', + 'wanderer.watch.checkedAt': 'Vérifié {date}', + 'wanderer.watch.noticeLabel': 'Vérification en arrière-plan', + 'wanderer.watch.state.possible_change': 'A peut-être changé', + 'wanderer.watch.state.changed': 'Modifié', + 'wanderer.watch.state.unverifiable': 'Non vérifiable', + 'wanderer.watch.stateHint.possible_change': + "wanderer a changé l'horodatage du sentier. Seule la vérification de la source montre si son contenu diffère.", + 'wanderer.watch.stateHint.changed': + 'La fiche wanderer indique un autre nom ou une autre description. La vérification de la source montre tout ce qui a changé.', + 'wanderer.watch.stateHint.unverifiable': + "TREK n'a pas pu juger ce sentier : rien n'est dit sur un éventuel changement.", + 'wanderer.watch.reason.unavailable': 'Sentier inaccessible', + 'wanderer.watch.reason.no_change_time': "Pas d'horodatage", +}; +export default wandererWatch; diff --git a/shared/src/i18n/gr/externalNotifications.ts b/shared/src/i18n/gr/externalNotifications.ts index a6d0f0b80..8ef80ae30 100644 --- a/shared/src/i18n/gr/externalNotifications.ts +++ b/shared/src/i18n/gr/externalNotifications.ts @@ -78,6 +78,15 @@ const gr: NotificationLocale = { title: 'Η σύνδεση Synology τερματίστηκε', body: 'Ο λογαριασμός σας Synology ή το URL άλλαξε. Έχετε αποσυνδεθεί από το Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Μονοπάτι wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» στο «${p.trip}» άλλαξε στο wanderer. Άνοιξε τη διαδρομή και έλεγξε την πηγή της για να δεις όλες τις αλλαγές. Στο ταξίδι τίποτα δεν αλλάζει από μόνο του.` + : p.state === 'unverifiable' + ? `«${p.tour}» στο «${p.trip}» δεν ελέγχεται πια στο wanderer. Η διαδρομή στο ταξίδι σου μένει ως έχει.` + : `«${p.tour}» στο «${p.trip}» ίσως άλλαξε στο wanderer. Άνοιξε τη διαδρομή και έλεγξε την πηγή της. Στο ταξίδι τίποτα δεν αλλάζει από μόνο του.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/gr/index.ts b/shared/src/i18n/gr/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/gr/index.ts +++ b/shared/src/i18n/gr/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/gr/notif.ts b/shared/src/i18n/gr/notif.ts index dddd00d53..ef8ae2d50 100644 --- a/shared/src/i18n/gr/notif.ts +++ b/shared/src/i18n/gr/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Προβολή Vacay', 'notif.action.view_collection': 'Προβολή συλλογής', 'notif.action.view_admin': 'Μετάβαση στη Διαχείριση', + 'notif.wanderer_source_hint.title': 'Μονοπάτι wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» στο {trip} ίσως άλλαξε στο wanderer', + 'notif.wanderer_source_hint.changed': '«{tour}» στο {trip} άλλαξε στο wanderer', + 'notif.wanderer_source_hint.unverifiable': '«{tour}» στο {trip} δεν ελέγχεται πια στο wanderer', + 'notif.action.view_tour': 'Προβολή διαδρομής', 'notif.action.view': 'Προβολή', 'notif.action.accept': 'Αποδοχή', 'notif.action.decline': 'Απόρριψη', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index e915c334b..d33d8d1fd 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -111,6 +111,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Κοινόχρηστες φωτογραφίες (Immich)', 'settings.notifyCollabMessage': 'Μηνύματα συνομιλίας (Collab)', 'settings.notifyPackingTagged': 'Λίστα πακεταρίσματος: αναθέσεις', + 'settings.notifyWandererSourceHint': 'wanderer: υποδείξεις για τα μονοπάτια σου', 'settings.notifyWebhook': 'Ειδοποιήσεις Webhook', 'settings.notifyVersionAvailable': 'Διαθέσιμη νέα έκδοση', 'settings.notifyReplicaFailure': 'Αποτυχία αντιγράφου αποθήκευσης', diff --git a/shared/src/i18n/gr/wandererWatch.ts b/shared/src/i18n/gr/wandererWatch.ts new file mode 100644 index 000000000..e731fcf55 --- /dev/null +++ b/shared/src/i18n/gr/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Έλεγχος αλλαγών στα μονοπάτια μου', + 'wanderer.watch.hint': 'Μια ειδοποίηση όταν ένα εισαγμένο μονοπάτι μπορεί να άλλαξε.', + 'wanderer.watch.info': + 'Το πολύ μία φορά την ημέρα, το TREK διαβάζει τη σύντομη εγγραφή κάθε μονοπατιού που εισήγαγες, ποτέ το αρχείο GPX του. Μόνο σε ειδοποιεί: αν θα περάσει μια αλλαγή το αποφασίζεις εσύ, με τον έλεγχο της πηγής της διαδρομής.', + 'wanderer.watch.notConnected': 'Σύνδεσε πρώτα το wanderer', + 'wanderer.watch.on': 'Ο έλεγχος στο παρασκήνιο ενεργοποιήθηκε', + 'wanderer.watch.off': 'Ο έλεγχος στο παρασκήνιο απενεργοποιήθηκε', + 'wanderer.watch.failed': 'Δεν ήταν δυνατή η αλλαγή του ελέγχου στο παρασκήνιο', + 'wanderer.watch.lastRun': 'Τελευταίος έλεγχος {date}', + 'wanderer.watch.neverRun': 'Δεν έχει ελεγχθεί ακόμη', + 'wanderer.watch.paused': 'Σε παύση έως {time}', + 'wanderer.watch.pausedHint': + 'Το wanderer δεν απάντησε. Μετά από κάθε αποτυχία, το TREK περιμένει περισσότερο πριν ρωτήσει ξανά.', + 'wanderer.watch.blocked': 'Το διακριτικό απορρίφθηκε', + 'wanderer.watch.blockedHint': + 'Το wanderer απέρριψε το διακριτικό API. Ο έλεγχος συνεχίζεται μόλις πετύχει μια δοκιμή σύνδεσης ή αποθηκευτεί νέο διακριτικό.', + 'wanderer.watch.hints': 'Υποδείξεις', + 'wanderer.watch.hintsCount': '{count} ανοιχτές υποδείξεις', + 'wanderer.watch.hintsCount.one': '{count} ανοιχτή υπόδειξη', + 'wanderer.watch.empty': 'Καμία ανοιχτή υπόδειξη', + 'wanderer.watch.dialogTitle': 'Υποδείξεις για τα μονοπάτια σου', + 'wanderer.watch.openTour': 'Άνοιγμα διαδρομής', + 'wanderer.watch.checkedAt': 'Ελέγχθηκε {date}', + 'wanderer.watch.noticeLabel': 'Έλεγχος στο παρασκήνιο', + 'wanderer.watch.state.possible_change': 'Ίσως άλλαξε', + 'wanderer.watch.state.changed': 'Άλλαξε', + 'wanderer.watch.state.unverifiable': 'Δεν ελέγχεται', + 'wanderer.watch.stateHint.possible_change': + 'Το wanderer άλλαξε τη χρονοσφραγίδα του μονοπατιού. Μόνο ο έλεγχος της πηγής δείχνει αν διαφέρει το περιεχόμενο.', + 'wanderer.watch.stateHint.changed': + 'Η εγγραφή στο wanderer δείχνει άλλο όνομα ή άλλη περιγραφή. Ο έλεγχος της πηγής δείχνει όλα όσα άλλαξαν.', + 'wanderer.watch.stateHint.unverifiable': + 'Το TREK δεν μπόρεσε να κρίνει αυτό το μονοπάτι, οπότε δεν λέγεται τίποτα για αλλαγή.', + 'wanderer.watch.reason.unavailable': 'Μονοπάτι μη προσβάσιμο', + 'wanderer.watch.reason.no_change_time': 'Χωρίς χρονοσφραγίδα', +}; +export default wandererWatch; diff --git a/shared/src/i18n/hu/externalNotifications.ts b/shared/src/i18n/hu/externalNotifications.ts index f01c008ac..e8da4e084 100644 --- a/shared/src/i18n/hu/externalNotifications.ts +++ b/shared/src/i18n/hu/externalNotifications.ts @@ -75,6 +75,15 @@ const hu: NotificationLocale = { title: 'Synology munkamenet törölve', body: 'A Synology fiókja vagy URL-je megváltozott. Kijelentkeztek a Synology Photos-ból.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer útvonal: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}” („${p.trip}”) megváltozott a wandererben. Nyisd meg a túrát, és ellenőrizd a forrását, hogy minden változást láss. Az utazásban semmi sem változik magától.` + : p.state === 'unverifiable' + ? `„${p.tour}” („${p.trip}”) már nem ellenőrizhető a wandererben. A túra az utazásodban változatlan marad.` + : `„${p.tour}” („${p.trip}”) megváltozhatott a wandererben. Nyisd meg a túrát, és ellenőrizd a forrását. Az utazásban semmi sem változik magától.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/hu/index.ts b/shared/src/i18n/hu/index.ts index 0a4677de3..3acc5e18e 100644 --- a/shared/src/i18n/hu/index.ts +++ b/shared/src/i18n/hu/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/hu/notif.ts b/shared/src/i18n/hu/notif.ts index 47963a202..855dbddf4 100644 --- a/shared/src/i18n/hu/notif.ts +++ b/shared/src/i18n/hu/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vacay megtekintése', 'notif.action.view_collection': 'Gyűjtemény megtekintése', 'notif.action.view_admin': 'Admin megnyitása', + 'notif.wanderer_source_hint.title': 'wanderer útvonal', + 'notif.wanderer_source_hint.possible_change': '„{tour}” ({trip}) megváltozhatott a wandererben', + 'notif.wanderer_source_hint.changed': '„{tour}” ({trip}) megváltozott a wandererben', + 'notif.wanderer_source_hint.unverifiable': '„{tour}” ({trip}) már nem ellenőrizhető a wandererben', + 'notif.action.view_tour': 'Túra megtekintése', 'notif.action.view': 'Megtekintés', 'notif.action.accept': 'Elfogadás', 'notif.action.decline': 'Elutasítás', diff --git a/shared/src/i18n/hu/settings.ts b/shared/src/i18n/hu/settings.ts index 4ea56c596..52fad2903 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -104,6 +104,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Megosztott fotók (Immich)', 'settings.notifyCollabMessage': 'Csevegés üzenetek (Collab)', 'settings.notifyPackingTagged': 'Csomagolási lista: hozzárendelések', + 'settings.notifyWandererSourceHint': 'wanderer: jelzések az útvonalaidról', 'settings.notifyWebhook': 'Webhook értesítések', 'settings.notificationsDisabled': 'Az értesítések nincsenek beállítva. Kérje meg a rendszergazdát, hogy engedélyezze az e-mail vagy webhook értesítéseket.', diff --git a/shared/src/i18n/hu/wandererWatch.ts b/shared/src/i18n/hu/wandererWatch.ts new file mode 100644 index 000000000..cf5ea10cd --- /dev/null +++ b/shared/src/i18n/hu/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Útvonalaim változásainak ellenőrzése', + 'wanderer.watch.hint': 'Értesítés, ha egy importált útvonal megváltozhatott.', + 'wanderer.watch.info': + 'Legfeljebb naponta egyszer a TREK beolvassa minden importált útvonal rövid rekordját, a GPX-fájlját soha. Csak értesít: hogy átveszel-e egy változást, te döntöd el a túra forrásának ellenőrzésével.', + 'wanderer.watch.notConnected': 'Előbb csatlakoztasd a wanderert', + 'wanderer.watch.on': 'Háttérellenőrzés bekapcsolva', + 'wanderer.watch.off': 'Háttérellenőrzés kikapcsolva', + 'wanderer.watch.failed': 'A háttérellenőrzést nem sikerült átkapcsolni', + 'wanderer.watch.lastRun': 'Utoljára ellenőrizve {date}', + 'wanderer.watch.neverRun': 'Még nincs ellenőrizve', + 'wanderer.watch.paused': 'Szüneteltetve eddig: {time}', + 'wanderer.watch.pausedHint': 'A wanderer nem válaszolt. Minden hiba után a TREK tovább vár, mielőtt újra kérdez.', + 'wanderer.watch.blocked': 'Token elutasítva', + 'wanderer.watch.blockedHint': + 'A wanderer elutasította az API-tokent. Az ellenőrzés folytatódik, amint egy kapcsolatteszt sikerül vagy új tokent mentesz.', + 'wanderer.watch.hints': 'Jelzések', + 'wanderer.watch.hintsCount': '{count} nyitott jelzés', + 'wanderer.watch.hintsCount.one': '{count} nyitott jelzés', + 'wanderer.watch.empty': 'Nincs nyitott jelzés', + 'wanderer.watch.dialogTitle': 'Jelzések az útvonalaidról', + 'wanderer.watch.openTour': 'Túra megnyitása', + 'wanderer.watch.checkedAt': 'Ellenőrizve {date}', + 'wanderer.watch.noticeLabel': 'Háttérellenőrzés', + 'wanderer.watch.state.possible_change': 'Változhatott', + 'wanderer.watch.state.changed': 'Megváltozott', + 'wanderer.watch.state.unverifiable': 'Nem ellenőrizhető', + 'wanderer.watch.stateHint.possible_change': + 'A wanderer megváltoztatta az útvonal időbélyegét. Csak a forrás ellenőrzése mutatja meg, hogy a tartalom eltér-e.', + 'wanderer.watch.stateHint.changed': + 'A wanderer rekordja más nevet vagy leírást mutat. A forrás ellenőrzése mindent megmutat, ami változott.', + 'wanderer.watch.stateHint.unverifiable': + 'A TREK nem tudta megítélni ezt az útvonalat, így a változásról semmit sem állít.', + 'wanderer.watch.reason.unavailable': 'Az útvonal nem érhető el', + 'wanderer.watch.reason.no_change_time': 'Nincs időbélyeg', +}; +export default wandererWatch; diff --git a/shared/src/i18n/id/externalNotifications.ts b/shared/src/i18n/id/externalNotifications.ts index dc0c8850e..4080081f6 100644 --- a/shared/src/i18n/id/externalNotifications.ts +++ b/shared/src/i18n/id/externalNotifications.ts @@ -66,6 +66,15 @@ const id: NotificationLocale = { title: 'Sesi Synology dihapus', body: 'Akun atau URL Synology Anda berubah. Anda telah keluar dari Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Jalur wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `“${p.tour}” di “${p.trip}” telah diubah di wanderer. Buka tur dan periksa sumbernya untuk melihat semua perubahan. Tidak ada yang berubah sendiri di perjalanan.` + : p.state === 'unverifiable' + ? `“${p.tour}” di “${p.trip}” tidak dapat diperiksa lagi di wanderer. Tur di perjalanan Anda tetap seperti semula.` + : `“${p.tour}” di “${p.trip}” mungkin berubah di wanderer. Buka tur dan periksa sumbernya. Tidak ada yang berubah sendiri di perjalanan.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/id/index.ts b/shared/src/i18n/id/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/id/index.ts +++ b/shared/src/i18n/id/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/id/notif.ts b/shared/src/i18n/id/notif.ts index 439706c51..57f5c482a 100644 --- a/shared/src/i18n/id/notif.ts +++ b/shared/src/i18n/id/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Lihat Vacay', 'notif.action.view_collection': 'Lihat koleksi', 'notif.action.view_admin': 'Ke Admin', + 'notif.wanderer_source_hint.title': 'Jalur wanderer', + 'notif.wanderer_source_hint.possible_change': '“{tour}” di {trip} mungkin berubah di wanderer', + 'notif.wanderer_source_hint.changed': '“{tour}” di {trip} telah diubah di wanderer', + 'notif.wanderer_source_hint.unverifiable': '“{tour}” di {trip} tidak dapat diperiksa lagi di wanderer', + 'notif.action.view_tour': 'Lihat tur', 'notif.action.view': 'Lihat', 'notif.action.accept': 'Terima', 'notif.action.decline': 'Tolak', diff --git a/shared/src/i18n/id/settings.ts b/shared/src/i18n/id/settings.ts index 5deae9cc6..1ad544059 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -102,6 +102,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Foto dibagikan (Immich)', 'settings.notifyCollabMessage': 'Pesan chat (Collab)', 'settings.notifyPackingTagged': 'Daftar bawaan: penugasan', + 'settings.notifyWandererSourceHint': 'wanderer: petunjuk tentang jalur Anda', 'settings.notifyWebhook': 'Notifikasi webhook', 'settings.notifyVersionAvailable': 'Versi baru tersedia', 'settings.notifyReplicaFailure': 'Kegagalan replika penyimpanan', diff --git a/shared/src/i18n/id/wandererWatch.ts b/shared/src/i18n/id/wandererWatch.ts new file mode 100644 index 000000000..b72fc6b0a --- /dev/null +++ b/shared/src/i18n/id/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Periksa perubahan jalur saya', + 'wanderer.watch.hint': 'Notifikasi saat jalur yang diimpor mungkin telah berubah.', + 'wanderer.watch.info': + 'Paling banyak sekali sehari, TREK membaca catatan singkat setiap jalur yang Anda impor, tidak pernah file GPX-nya. TREK hanya memberi tahu: menerima perubahan atau tidak, Anda sendiri yang memutuskan lewat pemeriksaan sumber tur.', + 'wanderer.watch.notConnected': 'Hubungkan wanderer terlebih dahulu', + 'wanderer.watch.on': 'Pemeriksaan latar belakang diaktifkan', + 'wanderer.watch.off': 'Pemeriksaan latar belakang dinonaktifkan', + 'wanderer.watch.failed': 'Pemeriksaan latar belakang tidak dapat diubah', + 'wanderer.watch.lastRun': 'Terakhir diperiksa {date}', + 'wanderer.watch.neverRun': 'Belum diperiksa', + 'wanderer.watch.paused': 'Dijeda hingga {time}', + 'wanderer.watch.pausedHint': + 'wanderer tidak menjawab. Setelah setiap kegagalan, TREK menunggu lebih lama sebelum bertanya lagi.', + 'wanderer.watch.blocked': 'Token ditolak', + 'wanderer.watch.blockedHint': + 'wanderer menolak token API. Pemeriksaan berlanjut setelah tes koneksi berhasil atau token baru disimpan.', + 'wanderer.watch.hints': 'Petunjuk', + 'wanderer.watch.hintsCount': '{count} petunjuk terbuka', + 'wanderer.watch.empty': 'Tidak ada petunjuk terbuka', + 'wanderer.watch.dialogTitle': 'Petunjuk tentang jalur Anda', + 'wanderer.watch.openTour': 'Buka tur', + 'wanderer.watch.checkedAt': 'Diperiksa {date}', + 'wanderer.watch.noticeLabel': 'Pemeriksaan latar belakang', + 'wanderer.watch.state.possible_change': 'Mungkin berubah', + 'wanderer.watch.state.changed': 'Berubah', + 'wanderer.watch.state.unverifiable': 'Tidak dapat diperiksa', + 'wanderer.watch.stateHint.possible_change': + 'wanderer mengubah stempel waktu jalur. Hanya pemeriksaan sumber yang menunjukkan apakah isinya berbeda.', + 'wanderer.watch.stateHint.changed': + 'Catatan di wanderer menunjukkan nama atau deskripsi lain. Pemeriksaan sumber menunjukkan semua yang berubah.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK tidak dapat menilai jalur ini, jadi tidak ada yang dikatakan tentang perubahannya.', + 'wanderer.watch.reason.unavailable': 'Jalur tidak dapat dijangkau', + 'wanderer.watch.reason.no_change_time': 'Tanpa stempel waktu', +}; +export default wandererWatch; diff --git a/shared/src/i18n/it/externalNotifications.ts b/shared/src/i18n/it/externalNotifications.ts index 1bcc6940b..5e2ef747e 100644 --- a/shared/src/i18n/it/externalNotifications.ts +++ b/shared/src/i18n/it/externalNotifications.ts @@ -72,6 +72,15 @@ const it: NotificationLocale = { title: 'Sessione Synology rimossa', body: 'Il tuo account o URL Synology è cambiato. Sei stato disconnesso da Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Sentiero wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» in «${p.trip}» è stato modificato in wanderer. Apri il percorso e controlla la sua fonte per vedere tutte le modifiche. Nel viaggio non cambia nulla da solo.` + : p.state === 'unverifiable' + ? `«${p.tour}» in «${p.trip}» non si può più controllare in wanderer. Il percorso nel tuo viaggio resta com'è.` + : `«${p.tour}» in «${p.trip}» potrebbe essere cambiato in wanderer. Apri il percorso e controlla la sua fonte per saperlo. Nel viaggio non cambia nulla da solo.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/it/index.ts b/shared/src/i18n/it/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/it/index.ts +++ b/shared/src/i18n/it/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/it/notif.ts b/shared/src/i18n/it/notif.ts index 5bdab4356..519573425 100644 --- a/shared/src/i18n/it/notif.ts +++ b/shared/src/i18n/it/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vedi Vacay', 'notif.action.view_collection': 'Vedi raccolta', 'notif.action.view_admin': "Vai all'admin", + 'notif.wanderer_source_hint.title': 'Sentiero wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» in {trip} potrebbe essere cambiato in wanderer', + 'notif.wanderer_source_hint.changed': '«{tour}» in {trip} è stato modificato in wanderer', + 'notif.wanderer_source_hint.unverifiable': '«{tour}» in {trip} non si può più controllare in wanderer', + 'notif.action.view_tour': 'Vedi percorso', 'notif.action.view': 'Vedi', 'notif.action.accept': 'Accetta', 'notif.action.decline': 'Rifiuta', diff --git a/shared/src/i18n/it/settings.ts b/shared/src/i18n/it/settings.ts index 61db47482..55c6f7253 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -104,6 +104,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Foto condivise (Immich)', 'settings.notifyCollabMessage': 'Messaggi chat (Collab)', 'settings.notifyPackingTagged': 'Lista valigia: assegnazioni', + 'settings.notifyWandererSourceHint': 'wanderer: segnalazioni sui tuoi sentieri', 'settings.notifyWebhook': 'Notifiche webhook', 'settings.notificationsDisabled': 'Le notifiche non sono configurate. Chiedi a un amministratore di abilitare le notifiche e-mail o webhook.', diff --git a/shared/src/i18n/it/wandererWatch.ts b/shared/src/i18n/it/wandererWatch.ts new file mode 100644 index 000000000..c7a3bcb3f --- /dev/null +++ b/shared/src/i18n/it/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Controlla le modifiche ai miei sentieri', + 'wanderer.watch.hint': 'Una notifica quando un sentiero importato potrebbe essere cambiato.', + 'wanderer.watch.info': + 'Al massimo una volta al giorno TREK legge la breve scheda di ogni sentiero che hai importato, mai il suo file GPX. Ti avvisa soltanto: se accogliere una modifica lo decidi tu, con il controllo della fonte del percorso.', + 'wanderer.watch.notConnected': 'Collega prima wanderer', + 'wanderer.watch.on': 'Controllo in background attivato', + 'wanderer.watch.off': 'Controllo in background disattivato', + 'wanderer.watch.failed': 'Impossibile cambiare il controllo in background', + 'wanderer.watch.lastRun': 'Ultimo controllo {date}', + 'wanderer.watch.neverRun': 'Non ancora controllato', + 'wanderer.watch.paused': 'In pausa fino a {time}', + 'wanderer.watch.pausedHint': + 'wanderer non ha risposto. Dopo ogni errore TREK aspetta più a lungo prima di chiedere di nuovo.', + 'wanderer.watch.blocked': 'Token rifiutato', + 'wanderer.watch.blockedHint': + 'wanderer ha rifiutato il token API. Il controllo riprende quando un test di connessione riesce o viene salvato un nuovo token.', + 'wanderer.watch.hints': 'Segnalazioni', + 'wanderer.watch.hintsCount': '{count} segnalazioni aperte', + 'wanderer.watch.hintsCount.one': '{count} segnalazione aperta', + 'wanderer.watch.empty': 'Nessuna segnalazione aperta', + 'wanderer.watch.dialogTitle': 'Segnalazioni sui tuoi sentieri', + 'wanderer.watch.openTour': 'Apri il percorso', + 'wanderer.watch.checkedAt': 'Controllato {date}', + 'wanderer.watch.noticeLabel': 'Controllo in background', + 'wanderer.watch.state.possible_change': 'Forse cambiato', + 'wanderer.watch.state.changed': 'Cambiato', + 'wanderer.watch.state.unverifiable': 'Non verificabile', + 'wanderer.watch.stateHint.possible_change': + "wanderer ha cambiato l'orario di modifica del sentiero. Solo il controllo della fonte mostra se il contenuto è diverso.", + 'wanderer.watch.stateHint.changed': + "La scheda di wanderer mostra un altro nome o un'altra descrizione. Il controllo della fonte mostra tutto ciò che è cambiato.", + 'wanderer.watch.stateHint.unverifiable': + 'TREK non ha potuto valutare questo sentiero, quindi non si dice nulla su un eventuale cambiamento.', + 'wanderer.watch.reason.unavailable': 'Sentiero non raggiungibile', + 'wanderer.watch.reason.no_change_time': 'Nessun orario di modifica', +}; +export default wandererWatch; diff --git a/shared/src/i18n/ja/externalNotifications.ts b/shared/src/i18n/ja/externalNotifications.ts index a6583aac9..91aa8a30f 100644 --- a/shared/src/i18n/ja/externalNotifications.ts +++ b/shared/src/i18n/ja/externalNotifications.ts @@ -66,6 +66,15 @@ const ja: NotificationLocale = { title: 'Synologyセッションがクリアされました', body: 'SynologyアカウントまたはURLが変更されました。Synology Photosからログアウトされました。', }), + wanderer_source_hint: (p) => ({ + title: `wanderer のトレイル: ${p.tour}`, + body: + p.state === 'changed' + ? `「${p.trip}」の「${p.tour}」が wanderer で変更されました。ツアーを開いてソースを確認すると、すべての変更を確認できます。旅行の内容が勝手に変わることはありません。` + : p.state === 'unverifiable' + ? `「${p.trip}」の「${p.tour}」は wanderer で確認できなくなりました。旅行内のツアーはそのまま残ります。` + : `「${p.trip}」の「${p.tour}」が wanderer で変更された可能性があります。ツアーを開いてソースを確認してください。旅行の内容が勝手に変わることはありません。`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/ja/index.ts b/shared/src/i18n/ja/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/ja/index.ts +++ b/shared/src/i18n/ja/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ja/notif.ts b/shared/src/i18n/ja/notif.ts index 2a732d635..db1ac53c6 100644 --- a/shared/src/i18n/ja/notif.ts +++ b/shared/src/i18n/ja/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vacayを見る', 'notif.action.view_collection': 'コレクションを見る', 'notif.action.view_admin': '管理画面へ', + 'notif.wanderer_source_hint.title': 'wanderer のトレイル', + 'notif.wanderer_source_hint.possible_change': '{trip} の「{tour}」が wanderer で変更された可能性があります', + 'notif.wanderer_source_hint.changed': '{trip} の「{tour}」が wanderer で変更されました', + 'notif.wanderer_source_hint.unverifiable': '{trip} の「{tour}」は wanderer で確認できなくなりました', + 'notif.action.view_tour': 'ツアーを見る', 'notif.action.view': '表示', 'notif.action.accept': '承認', 'notif.action.decline': '拒否', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index bb38c19de..62029f5a0 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -103,6 +103,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': '共有写真(Immich)', 'settings.notifyCollabMessage': 'チャットメッセージ(Collab)', 'settings.notifyPackingTagged': '持ち物リスト:割り当て', + 'settings.notifyWandererSourceHint': 'wanderer:トレイルに関するお知らせ', 'settings.notifyWebhook': 'Webhook通知', 'settings.notifyVersionAvailable': '新しいバージョン', 'settings.notifyReplicaFailure': 'ストレージレプリカの障害', diff --git a/shared/src/i18n/ja/wandererWatch.ts b/shared/src/i18n/ja/wandererWatch.ts new file mode 100644 index 000000000..98ad664e2 --- /dev/null +++ b/shared/src/i18n/ja/wandererWatch.ts @@ -0,0 +1,34 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'トレイルの変更を確認する', + 'wanderer.watch.hint': 'インポートしたトレイルが変更された可能性があるときに通知します。', + 'wanderer.watch.info': + 'TREK はインポートした各トレイルの短い記録を1日に最大1回読み取ります。GPX ファイルは読み取りません。通知するだけで、変更を取り込むかどうかはツアーのソース確認からご自身で決めます。', + 'wanderer.watch.notConnected': '先に wanderer を接続してください', + 'wanderer.watch.on': 'バックグラウンド確認をオンにしました', + 'wanderer.watch.off': 'バックグラウンド確認をオフにしました', + 'wanderer.watch.failed': 'バックグラウンド確認を切り替えられませんでした', + 'wanderer.watch.lastRun': '最終確認 {date}', + 'wanderer.watch.neverRun': 'まだ確認していません', + 'wanderer.watch.paused': '{time} まで一時停止', + 'wanderer.watch.pausedHint': 'wanderer から応答がありませんでした。失敗するたびに TREK は次の問い合わせまでの間隔を長くします。', + 'wanderer.watch.blocked': 'トークンが拒否されました', + 'wanderer.watch.blockedHint': 'wanderer が API トークンを拒否しました。接続テストに成功するか新しいトークンを保存すると確認を再開します。', + 'wanderer.watch.hints': 'お知らせ', + 'wanderer.watch.hintsCount': '未対応のお知らせ {count} 件', + 'wanderer.watch.empty': '未対応のお知らせはありません', + 'wanderer.watch.dialogTitle': 'トレイルに関するお知らせ', + 'wanderer.watch.openTour': 'ツアーを開く', + 'wanderer.watch.checkedAt': '確認 {date}', + 'wanderer.watch.noticeLabel': 'バックグラウンド確認', + 'wanderer.watch.state.possible_change': '変更の可能性', + 'wanderer.watch.state.changed': '変更あり', + 'wanderer.watch.state.unverifiable': '確認できません', + 'wanderer.watch.stateHint.possible_change': 'wanderer でトレイルのタイムスタンプが変わりました。内容が違うかどうかはソース確認でしか分かりません。', + 'wanderer.watch.stateHint.changed': 'wanderer の記録で名前または説明が変わっています。変更内容のすべてはソース確認で表示されます。', + 'wanderer.watch.stateHint.unverifiable': 'TREK はこのトレイルを判断できなかったため、変更の有無については何も示しません。', + 'wanderer.watch.reason.unavailable': 'トレイルにアクセスできません', + 'wanderer.watch.reason.no_change_time': 'タイムスタンプなし', +}; +export default wandererWatch; diff --git a/shared/src/i18n/ko/externalNotifications.ts b/shared/src/i18n/ko/externalNotifications.ts index 68357f298..b2542fd8f 100644 --- a/shared/src/i18n/ko/externalNotifications.ts +++ b/shared/src/i18n/ko/externalNotifications.ts @@ -66,6 +66,15 @@ const ko: NotificationLocale = { title: 'Synology 세션이 초기화되었습니다', body: 'Synology 계정 또는 URL이 변경되었습니다. Synology Photos에서 로그아웃되었습니다.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer 트레일: ${p.tour}`, + body: + p.state === 'changed' + ? `"${p.trip}"의 "${p.tour}"이(가) wanderer에서 변경되었습니다. 투어를 열고 소스를 확인하면 모든 변경을 볼 수 있습니다. 여행의 내용은 저절로 바뀌지 않습니다.` + : p.state === 'unverifiable' + ? `"${p.trip}"의 "${p.tour}"은(는) 더 이상 wanderer에서 확인할 수 없습니다. 여행의 투어는 그대로 유지됩니다.` + : `"${p.trip}"의 "${p.tour}"이(가) wanderer에서 바뀌었을 수 있습니다. 투어를 열고 소스를 확인하세요. 여행의 내용은 저절로 바뀌지 않습니다.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/ko/index.ts b/shared/src/i18n/ko/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/ko/index.ts +++ b/shared/src/i18n/ko/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ko/notif.ts b/shared/src/i18n/ko/notif.ts index aecaf16ef..ad7fe65e3 100644 --- a/shared/src/i18n/ko/notif.ts +++ b/shared/src/i18n/ko/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vacay 보기', 'notif.action.view_collection': '컬렉션 보기', 'notif.action.view_admin': '관리자로 이동', + 'notif.wanderer_source_hint.title': 'wanderer 트레일', + 'notif.wanderer_source_hint.possible_change': '{trip}의 "{tour}"이(가) wanderer에서 바뀌었을 수 있습니다', + 'notif.wanderer_source_hint.changed': '{trip}의 "{tour}"이(가) wanderer에서 변경되었습니다', + 'notif.wanderer_source_hint.unverifiable': '{trip}의 "{tour}"은(는) 더 이상 wanderer에서 확인할 수 없습니다', + 'notif.action.view_tour': '투어 보기', 'notif.action.view': '보기', 'notif.action.accept': '수락', 'notif.action.decline': '거절', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index 6bc44fdcd..80e34ff36 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -103,6 +103,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': '공유된 사진 (Immich)', 'settings.notifyCollabMessage': '채팅 메시지 (Collab)', 'settings.notifyPackingTagged': '짐 목록: 배정', + 'settings.notifyWandererSourceHint': 'wanderer: 내 트레일에 관한 알림', 'settings.notifyWebhook': '웹훅 알림', 'settings.notifyVersionAvailable': '새 버전 사용 가능', 'settings.notifyReplicaFailure': '스토리지 복제본 오류', diff --git a/shared/src/i18n/ko/wandererWatch.ts b/shared/src/i18n/ko/wandererWatch.ts new file mode 100644 index 000000000..653bc2663 --- /dev/null +++ b/shared/src/i18n/ko/wandererWatch.ts @@ -0,0 +1,34 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': '내 트레일 변경 확인', + 'wanderer.watch.hint': '가져온 트레일이 바뀌었을 수 있을 때 알림을 받습니다.', + 'wanderer.watch.info': + 'TREK은 가져온 각 트레일의 짧은 기록을 하루 최대 한 번 읽으며 GPX 파일은 읽지 않습니다. 알림만 보내며, 변경을 반영할지는 투어의 소스 확인에서 직접 결정합니다.', + 'wanderer.watch.notConnected': '먼저 wanderer를 연결하세요', + 'wanderer.watch.on': '백그라운드 확인을 켰습니다', + 'wanderer.watch.off': '백그라운드 확인을 껐습니다', + 'wanderer.watch.failed': '백그라운드 확인을 전환하지 못했습니다', + 'wanderer.watch.lastRun': '마지막 확인 {date}', + 'wanderer.watch.neverRun': '아직 확인하지 않음', + 'wanderer.watch.paused': '{time}까지 일시 중지', + 'wanderer.watch.pausedHint': 'wanderer가 응답하지 않았습니다. 실패할 때마다 TREK은 다시 묻기 전에 더 오래 기다립니다.', + 'wanderer.watch.blocked': '토큰 거부됨', + 'wanderer.watch.blockedHint': 'wanderer가 API 토큰을 거부했습니다. 연결 테스트가 통과하거나 새 토큰을 저장하면 확인이 다시 시작됩니다.', + 'wanderer.watch.hints': '알림', + 'wanderer.watch.hintsCount': '열린 알림 {count}개', + 'wanderer.watch.empty': '열린 알림이 없습니다', + 'wanderer.watch.dialogTitle': '내 트레일에 관한 알림', + 'wanderer.watch.openTour': '투어 열기', + 'wanderer.watch.checkedAt': '확인 {date}', + 'wanderer.watch.noticeLabel': '백그라운드 확인', + 'wanderer.watch.state.possible_change': '바뀌었을 수 있음', + 'wanderer.watch.state.changed': '변경됨', + 'wanderer.watch.state.unverifiable': '확인 불가', + 'wanderer.watch.stateHint.possible_change': 'wanderer에서 트레일의 타임스탬프가 바뀌었습니다. 내용이 다른지는 소스 확인으로만 알 수 있습니다.', + 'wanderer.watch.stateHint.changed': 'wanderer 기록에 다른 이름이나 설명이 있습니다. 소스 확인에서 바뀐 내용을 모두 볼 수 있습니다.', + 'wanderer.watch.stateHint.unverifiable': 'TREK이 이 트레일을 판단할 수 없어 변경 여부에 대해 아무것도 말하지 않습니다.', + 'wanderer.watch.reason.unavailable': '트레일에 접근할 수 없음', + 'wanderer.watch.reason.no_change_time': '타임스탬프 없음', +}; +export default wandererWatch; diff --git a/shared/src/i18n/nl/externalNotifications.ts b/shared/src/i18n/nl/externalNotifications.ts index a7477f5ae..982920923 100644 --- a/shared/src/i18n/nl/externalNotifications.ts +++ b/shared/src/i18n/nl/externalNotifications.ts @@ -75,6 +75,15 @@ const nl: NotificationLocale = { title: 'Synology-sessie gewist', body: 'Je Synology-account of URL is gewijzigd. Je bent uitgelogd bij Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer-trail: ${p.tour}`, + body: + p.state === 'changed' + ? `‘${p.tour}’ in ‘${p.trip}’ is gewijzigd in wanderer. Open de tocht en controleer de bron om alle wijzigingen te zien. In de reis verandert niets vanzelf.` + : p.state === 'unverifiable' + ? `‘${p.tour}’ in ‘${p.trip}’ kan niet meer worden gecontroleerd in wanderer. De tocht in je reis blijft zoals hij is.` + : `‘${p.tour}’ in ‘${p.trip}’ is mogelijk gewijzigd in wanderer. Open de tocht en controleer de bron om het te zien. In de reis verandert niets vanzelf.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/nl/index.ts b/shared/src/i18n/nl/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/nl/index.ts +++ b/shared/src/i18n/nl/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/nl/notif.ts b/shared/src/i18n/nl/notif.ts index 3c7cf4a69..2a3316f39 100644 --- a/shared/src/i18n/nl/notif.ts +++ b/shared/src/i18n/nl/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Vacay bekijken', 'notif.action.view_collection': 'Collectie bekijken', 'notif.action.view_admin': 'Naar admin', + 'notif.wanderer_source_hint.title': 'wanderer-trail', + 'notif.wanderer_source_hint.possible_change': '‘{tour}’ in {trip} is mogelijk gewijzigd in wanderer', + 'notif.wanderer_source_hint.changed': '‘{tour}’ in {trip} is gewijzigd in wanderer', + 'notif.wanderer_source_hint.unverifiable': '‘{tour}’ in {trip} kan niet meer worden gecontroleerd in wanderer', + 'notif.action.view_tour': 'Tocht bekijken', 'notif.action.view': 'Bekijken', 'notif.action.accept': 'Accepteren', 'notif.action.decline': 'Weigeren', diff --git a/shared/src/i18n/nl/settings.ts b/shared/src/i18n/nl/settings.ts index 9b91dafbb..cb427168c 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -103,6 +103,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': "Gedeelde foto's (Immich)", 'settings.notifyCollabMessage': 'Chatberichten (Collab)', 'settings.notifyPackingTagged': 'Inpaklijst: toewijzingen', + 'settings.notifyWandererSourceHint': 'wanderer: meldingen over je trails', 'settings.notifyWebhook': 'Webhook-meldingen', 'settings.notificationsDisabled': 'Meldingen zijn niet geconfigureerd. Vraag een beheerder om e-mail- of webhookmeldingen in te schakelen.', diff --git a/shared/src/i18n/nl/wandererWatch.ts b/shared/src/i18n/nl/wandererWatch.ts new file mode 100644 index 000000000..7b1350691 --- /dev/null +++ b/shared/src/i18n/nl/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Mijn trails op wijzigingen controleren', + 'wanderer.watch.hint': 'Een melding wanneer een geïmporteerde trail mogelijk is gewijzigd.', + 'wanderer.watch.info': + 'Hooguit één keer per dag leest TREK de korte gegevens van elke trail die je hebt geïmporteerd, nooit het GPX-bestand. Het meldt alleen: of een wijziging wordt overgenomen, bepaal je zelf via de broncontrole van de tocht.', + 'wanderer.watch.notConnected': 'Koppel eerst wanderer', + 'wanderer.watch.on': 'Achtergrondcontrole ingeschakeld', + 'wanderer.watch.off': 'Achtergrondcontrole uitgeschakeld', + 'wanderer.watch.failed': 'De achtergrondcontrole kon niet worden omgezet', + 'wanderer.watch.lastRun': 'Laatst gecontroleerd {date}', + 'wanderer.watch.neverRun': 'Nog niet gecontroleerd', + 'wanderer.watch.paused': 'Gepauzeerd tot {time}', + 'wanderer.watch.pausedHint': + 'wanderer gaf geen antwoord. Na elke mislukking wacht TREK langer voordat het opnieuw vraagt.', + 'wanderer.watch.blocked': 'Token geweigerd', + 'wanderer.watch.blockedHint': + 'wanderer heeft het API-token geweigerd. De controle gaat verder zodra een verbindingstest slaagt of een nieuw token is opgeslagen.', + 'wanderer.watch.hints': 'Meldingen', + 'wanderer.watch.hintsCount': '{count} open meldingen', + 'wanderer.watch.hintsCount.one': '{count} open melding', + 'wanderer.watch.empty': 'Geen open meldingen', + 'wanderer.watch.dialogTitle': 'Meldingen over je trails', + 'wanderer.watch.openTour': 'Tocht openen', + 'wanderer.watch.checkedAt': 'Gecontroleerd {date}', + 'wanderer.watch.noticeLabel': 'Achtergrondcontrole', + 'wanderer.watch.state.possible_change': 'Mogelijk gewijzigd', + 'wanderer.watch.state.changed': 'Gewijzigd', + 'wanderer.watch.state.unverifiable': 'Niet te controleren', + 'wanderer.watch.stateHint.possible_change': + 'wanderer heeft het tijdstempel van de trail gewijzigd. Alleen de broncontrole laat zien of de inhoud verschilt.', + 'wanderer.watch.stateHint.changed': + 'De gegevens in wanderer tonen een andere naam of beschrijving. De broncontrole laat alles zien wat is gewijzigd.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK kon deze trail niet beoordelen, dus er wordt niets gezegd over een wijziging.', + 'wanderer.watch.reason.unavailable': 'Trail niet bereikbaar', + 'wanderer.watch.reason.no_change_time': 'Geen tijdstempel', +}; +export default wandererWatch; diff --git a/shared/src/i18n/pl/externalNotifications.ts b/shared/src/i18n/pl/externalNotifications.ts index c964e86ef..ffb6a4046 100644 --- a/shared/src/i18n/pl/externalNotifications.ts +++ b/shared/src/i18n/pl/externalNotifications.ts @@ -84,6 +84,15 @@ const pl: NotificationLocale = { title: 'Sesja Synology wyczyszczona', body: 'Twoje konto lub URL Synology uległo zmianie. Zostałeś wylogowany z Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Szlak wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}” w „${p.trip}” został zmieniony w wanderer. Otwórz trasę i sprawdź jej źródło, aby zobaczyć wszystkie zmiany. W podróży nic nie zmienia się samo.` + : p.state === 'unverifiable' + ? `„${p.tour}” w „${p.trip}” nie da się już sprawdzić w wanderer. Trasa w twojej podróży pozostaje bez zmian.` + : `„${p.tour}” w „${p.trip}” mógł się zmienić w wanderer. Otwórz trasę i sprawdź jej źródło. W podróży nic nie zmienia się samo.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/pl/index.ts b/shared/src/i18n/pl/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/pl/index.ts +++ b/shared/src/i18n/pl/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/pl/notif.ts b/shared/src/i18n/pl/notif.ts index b4c585d35..6ed836fe0 100644 --- a/shared/src/i18n/pl/notif.ts +++ b/shared/src/i18n/pl/notif.ts @@ -43,6 +43,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Zobacz Vacay', 'notif.action.view_collection': 'Zobacz kolekcję', 'notif.action.view_admin': 'Przejdź do admina', + 'notif.wanderer_source_hint.title': 'Szlak wanderer', + 'notif.wanderer_source_hint.possible_change': '„{tour}” w {trip} mógł się zmienić w wanderer', + 'notif.wanderer_source_hint.changed': '„{tour}” w {trip} został zmieniony w wanderer', + 'notif.wanderer_source_hint.unverifiable': '„{tour}” w {trip} nie da się już sprawdzić w wanderer', + 'notif.action.view_tour': 'Zobacz trasę', 'notif.action.view': 'Zobacz', 'notif.action.accept': 'Akceptuj', 'notif.action.decline': 'Odrzuć', diff --git a/shared/src/i18n/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 5d1fbbcaf..489c2193f 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -102,6 +102,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Udostępnione zdjęcia (Immich)', 'settings.notifyCollabMessage': 'Wiadomości czatu (Collab)', 'settings.notifyPackingTagged': 'Lista pakowania: przypisania', + 'settings.notifyWandererSourceHint': 'wanderer: wskazówki dotyczące twoich szlaków', 'settings.notifyWebhook': 'Powiadomienia Webhook', 'settings.notifyVersionAvailable': 'Nowa wersja dostępna', 'settings.notifyReplicaFailure': 'Awaria repliki magazynu', diff --git a/shared/src/i18n/pl/wandererWatch.ts b/shared/src/i18n/pl/wandererWatch.ts new file mode 100644 index 000000000..1f75db182 --- /dev/null +++ b/shared/src/i18n/pl/wandererWatch.ts @@ -0,0 +1,41 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Sprawdzaj zmiany moich szlaków', + 'wanderer.watch.hint': 'Powiadomienie, gdy zaimportowany szlak mógł się zmienić.', + 'wanderer.watch.info': + 'Najwyżej raz dziennie TREK czyta krótki wpis każdego zaimportowanego szlaku, nigdy jego pliku GPX. Tylko powiadamia: czy przejąć zmianę, decydujesz sam przez sprawdzenie źródła trasy.', + 'wanderer.watch.notConnected': 'Najpierw połącz wanderer', + 'wanderer.watch.on': 'Sprawdzanie w tle włączone', + 'wanderer.watch.off': 'Sprawdzanie w tle wyłączone', + 'wanderer.watch.failed': 'Nie udało się przełączyć sprawdzania w tle', + 'wanderer.watch.lastRun': 'Ostatnio sprawdzono {date}', + 'wanderer.watch.neverRun': 'Jeszcze nie sprawdzono', + 'wanderer.watch.paused': 'Wstrzymane do {time}', + 'wanderer.watch.pausedHint': + 'wanderer nie odpowiedział. Po każdym niepowodzeniu TREK czeka dłużej, zanim zapyta ponownie.', + 'wanderer.watch.blocked': 'Token odrzucony', + 'wanderer.watch.blockedHint': + 'wanderer odrzucił token API. Sprawdzanie wznowi się, gdy test połączenia się powiedzie lub zostanie zapisany nowy token.', + 'wanderer.watch.hints': 'Wskazówki', + 'wanderer.watch.hintsCount': '{count} otwartej wskazówki', + 'wanderer.watch.hintsCount.one': '{count} otwarta wskazówka', + 'wanderer.watch.hintsCount.few': '{count} otwarte wskazówki', + 'wanderer.watch.hintsCount.many': '{count} otwartych wskazówek', + 'wanderer.watch.empty': 'Brak otwartych wskazówek', + 'wanderer.watch.dialogTitle': 'Wskazówki dotyczące twoich szlaków', + 'wanderer.watch.openTour': 'Otwórz trasę', + 'wanderer.watch.checkedAt': 'Sprawdzono {date}', + 'wanderer.watch.noticeLabel': 'Sprawdzanie w tle', + 'wanderer.watch.state.possible_change': 'Mogło się zmienić', + 'wanderer.watch.state.changed': 'Zmieniono', + 'wanderer.watch.state.unverifiable': 'Nie do sprawdzenia', + 'wanderer.watch.stateHint.possible_change': + 'wanderer zmienił znacznik czasu szlaku. Dopiero sprawdzenie źródła pokaże, czy treść się różni.', + 'wanderer.watch.stateHint.changed': + 'Wpis w wanderer ma inną nazwę lub inny opis. Sprawdzenie źródła pokaże wszystko, co się zmieniło.', + 'wanderer.watch.stateHint.unverifiable': 'TREK nie mógł ocenić tego szlaku, więc nic nie wiadomo o zmianie.', + 'wanderer.watch.reason.unavailable': 'Szlak niedostępny', + 'wanderer.watch.reason.no_change_time': 'Brak znacznika czasu', +}; +export default wandererWatch; diff --git a/shared/src/i18n/ru/externalNotifications.ts b/shared/src/i18n/ru/externalNotifications.ts index c36384e4a..2ef91da5e 100644 --- a/shared/src/i18n/ru/externalNotifications.ts +++ b/shared/src/i18n/ru/externalNotifications.ts @@ -79,6 +79,15 @@ const ru: NotificationLocale = { title: 'Сессия Synology сброшена', body: 'Ваш аккаунт или URL Synology изменился. Вы вышли из Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Маршрут wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» в «${p.trip}» изменён в wanderer. Откройте тур и проверьте его источник, чтобы увидеть все изменения. В поездке ничего не меняется само.` + : p.state === 'unverifiable' + ? `«${p.tour}» в «${p.trip}» больше нельзя проверить в wanderer. Тур в вашей поездке остаётся как есть.` + : `«${p.tour}» в «${p.trip}», возможно, изменён в wanderer. Откройте тур и проверьте его источник. В поездке ничего не меняется само.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/ru/index.ts b/shared/src/i18n/ru/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/ru/index.ts +++ b/shared/src/i18n/ru/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/ru/notif.ts b/shared/src/i18n/ru/notif.ts index 852cdc351..e587759b8 100644 --- a/shared/src/i18n/ru/notif.ts +++ b/shared/src/i18n/ru/notif.ts @@ -43,6 +43,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Открыть Vacay', 'notif.action.view_collection': 'Открыть коллекцию', 'notif.action.view_admin': 'Перейти в админ', + 'notif.wanderer_source_hint.title': 'Маршрут wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» в {trip}, возможно, изменён в wanderer', + 'notif.wanderer_source_hint.changed': '«{tour}» в {trip} изменён в wanderer', + 'notif.wanderer_source_hint.unverifiable': '«{tour}» в {trip} больше нельзя проверить в wanderer', + 'notif.action.view_tour': 'Открыть тур', 'notif.action.view': 'Открыть', 'notif.action.accept': 'Принять', 'notif.action.decline': 'Отклонить', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index 8fec3d742..21de259bd 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -102,6 +102,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Общие фото (Immich)', 'settings.notifyCollabMessage': 'Сообщения чата (Collab)', 'settings.notifyPackingTagged': 'Список вещей: назначения', + 'settings.notifyWandererSourceHint': 'wanderer: подсказки по вашим маршрутам', 'settings.notifyWebhook': 'Webhook-уведомления', 'settings.notificationsDisabled': 'Уведомления не настроены. Попросите администратора включить уведомления по электронной почте или webhook.', diff --git a/shared/src/i18n/ru/wandererWatch.ts b/shared/src/i18n/ru/wandererWatch.ts new file mode 100644 index 000000000..d8a708884 --- /dev/null +++ b/shared/src/i18n/ru/wandererWatch.ts @@ -0,0 +1,41 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Проверять изменения моих маршрутов', + 'wanderer.watch.hint': 'Уведомление, если импортированный маршрут мог измениться.', + 'wanderer.watch.info': + 'Не чаще раза в день TREK читает краткую запись каждого импортированного маршрута, но никогда его файл GPX. Он только уведомляет: принимать ли изменение, решаете вы сами через проверку источника тура.', + 'wanderer.watch.notConnected': 'Сначала подключите wanderer', + 'wanderer.watch.on': 'Фоновая проверка включена', + 'wanderer.watch.off': 'Фоновая проверка выключена', + 'wanderer.watch.failed': 'Не удалось переключить фоновую проверку', + 'wanderer.watch.lastRun': 'Последняя проверка {date}', + 'wanderer.watch.neverRun': 'Ещё не проверялось', + 'wanderer.watch.paused': 'Приостановлено до {time}', + 'wanderer.watch.pausedHint': 'wanderer не ответил. После каждой неудачи TREK ждёт дольше, прежде чем спросить снова.', + 'wanderer.watch.blocked': 'Токен отклонён', + 'wanderer.watch.blockedHint': + 'wanderer отклонил API-токен. Проверка продолжится, когда пройдёт тест подключения или будет сохранён новый токен.', + 'wanderer.watch.hints': 'Подсказки', + 'wanderer.watch.hintsCount': '{count} открытой подсказки', + 'wanderer.watch.hintsCount.one': '{count} открытая подсказка', + 'wanderer.watch.hintsCount.few': '{count} открытые подсказки', + 'wanderer.watch.hintsCount.many': '{count} открытых подсказок', + 'wanderer.watch.empty': 'Открытых подсказок нет', + 'wanderer.watch.dialogTitle': 'Подсказки по вашим маршрутам', + 'wanderer.watch.openTour': 'Открыть тур', + 'wanderer.watch.checkedAt': 'Проверено {date}', + 'wanderer.watch.noticeLabel': 'Фоновая проверка', + 'wanderer.watch.state.possible_change': 'Возможно, изменён', + 'wanderer.watch.state.changed': 'Изменён', + 'wanderer.watch.state.unverifiable': 'Не проверить', + 'wanderer.watch.stateHint.possible_change': + 'wanderer изменил отметку времени маршрута. Только проверка источника покажет, отличается ли содержимое.', + 'wanderer.watch.stateHint.changed': + 'Запись в wanderer показывает другое название или описание. Проверка источника покажет всё, что изменилось.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK не смог оценить этот маршрут, поэтому об изменениях ничего не сказано.', + 'wanderer.watch.reason.unavailable': 'Маршрут недоступен', + 'wanderer.watch.reason.no_change_time': 'Нет отметки времени', +}; +export default wandererWatch; diff --git a/shared/src/i18n/sk/externalNotifications.ts b/shared/src/i18n/sk/externalNotifications.ts index c8109f0d2..562d3ca96 100644 --- a/shared/src/i18n/sk/externalNotifications.ts +++ b/shared/src/i18n/sk/externalNotifications.ts @@ -81,6 +81,15 @@ const sk: NotificationLocale = { title: 'Relácia Synology bola zrušená', body: 'Váš účet alebo URL Synology sa zmenili. Boli ste odhlásení zo Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Trasa wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `„${p.tour}“ v „${p.trip}“ bola vo wanderer zmenená. Otvorte túru a skontrolujte jej zdroj, aby ste videli všetky zmeny. V ceste sa nič nezmení samo.` + : p.state === 'unverifiable' + ? `„${p.tour}“ v „${p.trip}“ sa už vo wanderer nedá overiť. Túra vo vašej ceste zostáva, ako je.` + : `„${p.tour}“ v „${p.trip}“ sa vo wanderer možno zmenila. Otvorte túru a skontrolujte jej zdroj. V ceste sa nič nezmení samo.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/sk/index.ts b/shared/src/i18n/sk/index.ts index eacf3e31c..ccbf91396 100644 --- a/shared/src/i18n/sk/index.ts +++ b/shared/src/i18n/sk/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -76,6 +77,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/sk/notif.ts b/shared/src/i18n/sk/notif.ts index 4f570ccba..522ba42dc 100644 --- a/shared/src/i18n/sk/notif.ts +++ b/shared/src/i18n/sk/notif.ts @@ -42,6 +42,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Zobraziť Vacay', 'notif.action.view_collection': 'Zobraziť zbierku', 'notif.action.view_admin': 'Prejsť do administrácie', + 'notif.wanderer_source_hint.title': 'Trasa wanderer', + 'notif.wanderer_source_hint.possible_change': '„{tour}“ v {trip} sa vo wanderer možno zmenila', + 'notif.wanderer_source_hint.changed': '„{tour}“ v {trip} bola vo wanderer zmenená', + 'notif.wanderer_source_hint.unverifiable': '„{tour}“ v {trip} sa už vo wanderer nedá overiť', + 'notif.action.view_tour': 'Zobraziť túru', 'notif.action.view': 'Zobraziť', 'notif.action.accept': 'Prijať', 'notif.action.decline': 'Odmietnuť', diff --git a/shared/src/i18n/sk/settings.ts b/shared/src/i18n/sk/settings.ts index 62cc8dc8e..c3215bd79 100644 --- a/shared/src/i18n/sk/settings.ts +++ b/shared/src/i18n/sk/settings.ts @@ -116,6 +116,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Zdieľané fotky (Immich)', 'settings.notifyCollabMessage': 'Správy v chate (Collab)', 'settings.notifyPackingTagged': 'Zoznam balenia: priradenia', + 'settings.notifyWandererSourceHint': 'wanderer: upozornenia k vašim trasám', 'settings.notifyWebhook': 'Webhook oznámenia', 'settings.notifyVersionAvailable': 'Nová verzia k dispozícii', 'settings.notifyReplicaFailure': 'Zlyhanie repliky úložiska', diff --git a/shared/src/i18n/sk/wandererWatch.ts b/shared/src/i18n/sk/wandererWatch.ts new file mode 100644 index 000000000..3e9b8d32a --- /dev/null +++ b/shared/src/i18n/sk/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Kontrolovať zmeny mojich trás', + 'wanderer.watch.hint': 'Upozornenie, keď sa importovaná trasa mohla zmeniť.', + 'wanderer.watch.info': + 'Najviac raz denne TREK prečíta krátky záznam každej trasy, ktorú ste importovali, nikdy jej súbor GPX. Len vás upozorní: či zmenu prevezmete, rozhodnete sami kontrolou zdroja túry.', + 'wanderer.watch.notConnected': 'Najprv pripojte wanderer', + 'wanderer.watch.on': 'Kontrola na pozadí zapnutá', + 'wanderer.watch.off': 'Kontrola na pozadí vypnutá', + 'wanderer.watch.failed': 'Kontrolu na pozadí sa nepodarilo prepnúť', + 'wanderer.watch.lastRun': 'Naposledy skontrolované {date}', + 'wanderer.watch.neverRun': 'Zatiaľ neskontrolované', + 'wanderer.watch.paused': 'Pozastavené do {time}', + 'wanderer.watch.pausedHint': 'wanderer neodpovedal. Po každom neúspechu TREK čaká dlhšie, kým sa znova opýta.', + 'wanderer.watch.blocked': 'Token odmietnutý', + 'wanderer.watch.blockedHint': + 'wanderer odmietol token API. Kontrola pokračuje, keď prejde test pripojenia alebo sa uloží nový token.', + 'wanderer.watch.hints': 'Upozornenia', + 'wanderer.watch.hintsCount': '{count} otvorených upozornení', + 'wanderer.watch.hintsCount.one': '{count} otvorené upozornenie', + 'wanderer.watch.hintsCount.few': '{count} otvorené upozornenia', + 'wanderer.watch.empty': 'Žiadne otvorené upozornenia', + 'wanderer.watch.dialogTitle': 'Upozornenia k vašim trasám', + 'wanderer.watch.openTour': 'Otvoriť túru', + 'wanderer.watch.checkedAt': 'Skontrolované {date}', + 'wanderer.watch.noticeLabel': 'Kontrola na pozadí', + 'wanderer.watch.state.possible_change': 'Možno zmenené', + 'wanderer.watch.state.changed': 'Zmenené', + 'wanderer.watch.state.unverifiable': 'Nedá sa overiť', + 'wanderer.watch.stateHint.possible_change': + 'wanderer zmenil časovú pečiatku trasy. Len kontrola zdroja ukáže, či sa líši obsah.', + 'wanderer.watch.stateHint.changed': + 'Záznam vo wanderer ukazuje iný názov alebo popis. Kontrola zdroja ukáže všetko, čo sa zmenilo.', + 'wanderer.watch.stateHint.unverifiable': 'TREK túto trasu nemohol posúdiť, takže sa o zmene nič nehovorí.', + 'wanderer.watch.reason.unavailable': 'Trasa nie je dostupná', + 'wanderer.watch.reason.no_change_time': 'Bez časovej pečiatky', +}; +export default wandererWatch; diff --git a/shared/src/i18n/sv/externalNotifications.ts b/shared/src/i18n/sv/externalNotifications.ts index 71147b54c..25a926303 100644 --- a/shared/src/i18n/sv/externalNotifications.ts +++ b/shared/src/i18n/sv/externalNotifications.ts @@ -75,6 +75,15 @@ const en: NotificationLocale = { title: 'Synology session rensad', body: 'Ditt Synology-konto eller din webbadress har ändrats. Du har loggats ut från Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer-led: ${p.tour}`, + body: + p.state === 'changed' + ? `”${p.tour}” i ”${p.trip}” har ändrats i wanderer. Öppna turen och kontrollera dess källa för att se alla ändringar. Inget i resan ändras av sig självt.` + : p.state === 'unverifiable' + ? `”${p.tour}” i ”${p.trip}” kan inte längre kontrolleras i wanderer. Turen i din resa förblir som den är.` + : `”${p.tour}” i ”${p.trip}” kan ha ändrats i wanderer. Öppna turen och kontrollera dess källa. Inget i resan ändras av sig självt.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/sv/index.ts b/shared/src/i18n/sv/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/sv/index.ts +++ b/shared/src/i18n/sv/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/sv/notif.ts b/shared/src/i18n/sv/notif.ts index ff41860b1..3160eede7 100644 --- a/shared/src/i18n/sv/notif.ts +++ b/shared/src/i18n/sv/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Visa Vacay', 'notif.action.view_collection': 'Visa samling', 'notif.action.view_admin': 'Gå till Admin', + 'notif.wanderer_source_hint.title': 'wanderer-led', + 'notif.wanderer_source_hint.possible_change': '”{tour}” i {trip} kan ha ändrats i wanderer', + 'notif.wanderer_source_hint.changed': '”{tour}” i {trip} har ändrats i wanderer', + 'notif.wanderer_source_hint.unverifiable': '”{tour}” i {trip} kan inte längre kontrolleras i wanderer', + 'notif.action.view_tour': 'Visa turen', 'notif.action.view': 'Visa', 'notif.action.accept': 'Godkänn', 'notif.action.decline': 'Neka', diff --git a/shared/src/i18n/sv/settings.ts b/shared/src/i18n/sv/settings.ts index ff07c938e..38b6283d9 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -109,6 +109,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Delade foton (Immich)', 'settings.notifyCollabMessage': 'Chatt meddelande (Samarbete)', 'settings.notifyPackingTagged': 'Packningslista: uppgifter', + 'settings.notifyWandererSourceHint': 'wanderer: tips om dina leder', 'settings.notifyWebhook': 'Webhook meddelanden', 'settings.notifyVersionAvailable': 'Ny version tillgänglig', 'settings.notifyReplicaFailure': 'Fel i lagringsreplik', diff --git a/shared/src/i18n/sv/wandererWatch.ts b/shared/src/i18n/sv/wandererWatch.ts new file mode 100644 index 000000000..20a958e32 --- /dev/null +++ b/shared/src/i18n/sv/wandererWatch.ts @@ -0,0 +1,39 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Kontrollera ändringar i mina leder', + 'wanderer.watch.hint': 'En avisering när en importerad led kan ha ändrats.', + 'wanderer.watch.info': + 'Högst en gång om dagen läser TREK den korta posten för varje led du har importerat, aldrig dess GPX-fil. Den aviserar bara: om en ändring ska tas in bestämmer du själv via turens källkontroll.', + 'wanderer.watch.notConnected': 'Anslut wanderer först', + 'wanderer.watch.on': 'Bakgrundskontroll påslagen', + 'wanderer.watch.off': 'Bakgrundskontroll avstängd', + 'wanderer.watch.failed': 'Bakgrundskontrollen kunde inte ändras', + 'wanderer.watch.lastRun': 'Senast kontrollerad {date}', + 'wanderer.watch.neverRun': 'Inte kontrollerad än', + 'wanderer.watch.paused': 'Pausad till {time}', + 'wanderer.watch.pausedHint': + 'wanderer svarade inte. Efter varje misslyckande väntar TREK längre innan det frågar igen.', + 'wanderer.watch.blocked': 'Token nekad', + 'wanderer.watch.blockedHint': + 'wanderer nekade API-token. Kontrollen fortsätter när ett anslutningstest lyckas eller en ny token sparas.', + 'wanderer.watch.hints': 'Tips', + 'wanderer.watch.hintsCount': '{count} öppna tips', + 'wanderer.watch.hintsCount.one': '{count} öppet tips', + 'wanderer.watch.empty': 'Inga öppna tips', + 'wanderer.watch.dialogTitle': 'Tips om dina leder', + 'wanderer.watch.openTour': 'Öppna turen', + 'wanderer.watch.checkedAt': 'Kontrollerad {date}', + 'wanderer.watch.noticeLabel': 'Bakgrundskontroll', + 'wanderer.watch.state.possible_change': 'Kan ha ändrats', + 'wanderer.watch.state.changed': 'Ändrad', + 'wanderer.watch.state.unverifiable': 'Kan inte kontrolleras', + 'wanderer.watch.stateHint.possible_change': + 'wanderer ändrade ledens tidsstämpel. Bara källkontrollen visar om innehållet skiljer sig.', + 'wanderer.watch.stateHint.changed': + 'Posten i wanderer visar ett annat namn eller en annan beskrivning. Källkontrollen visar allt som ändrats.', + 'wanderer.watch.stateHint.unverifiable': 'TREK kunde inte bedöma leden, så inget sägs om huruvida den ändrats.', + 'wanderer.watch.reason.unavailable': 'Leden går inte att nå', + 'wanderer.watch.reason.no_change_time': 'Ingen tidsstämpel', +}; +export default wandererWatch; diff --git a/shared/src/i18n/th/externalNotifications.ts b/shared/src/i18n/th/externalNotifications.ts index c72fa9c51..89da087c3 100644 --- a/shared/src/i18n/th/externalNotifications.ts +++ b/shared/src/i18n/th/externalNotifications.ts @@ -68,6 +68,15 @@ const th: NotificationLocale = { title: 'ล้างเซสชัน Synology แล้ว', body: 'บัญชีหรือ URL ของ Synology เปลี่ยนแปลง คุณจึงออกจากระบบ Synology Photos แล้ว', }), + wanderer_source_hint: (p) => ({ + title: `เส้นทาง wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `"${p.tour}" ใน "${p.trip}" ถูกเปลี่ยนใน wanderer เปิดทัวร์และตรวจแหล่งที่มาเพื่อดูการเปลี่ยนแปลงทั้งหมด ไม่มีสิ่งใดในทริปเปลี่ยนเอง` + : p.state === 'unverifiable' + ? `"${p.tour}" ใน "${p.trip}" ตรวจใน wanderer ไม่ได้อีกต่อไป ทัวร์ในทริปของคุณยังคงเหมือนเดิม` + : `"${p.tour}" ใน "${p.trip}" อาจเปลี่ยนแปลงใน wanderer เปิดทัวร์และตรวจแหล่งที่มา ไม่มีสิ่งใดในทริปเปลี่ยนเอง`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/th/index.ts b/shared/src/i18n/th/index.ts index eacf3e31c..ccbf91396 100644 --- a/shared/src/i18n/th/index.ts +++ b/shared/src/i18n/th/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -76,6 +77,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...places, ...inspector, ...reservations, diff --git a/shared/src/i18n/th/notif.ts b/shared/src/i18n/th/notif.ts index d9cec8916..1b7f1c626 100644 --- a/shared/src/i18n/th/notif.ts +++ b/shared/src/i18n/th/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'ดูวันหยุดพักผ่อน', 'notif.action.view_collection': 'ดูคอลเลกชัน', 'notif.action.view_admin': 'ไปที่ผู้ดูแลระบบ', + 'notif.wanderer_source_hint.title': 'เส้นทาง wanderer', + 'notif.wanderer_source_hint.possible_change': '"{tour}" ใน {trip} อาจเปลี่ยนแปลงใน wanderer', + 'notif.wanderer_source_hint.changed': '"{tour}" ใน {trip} ถูกเปลี่ยนใน wanderer', + 'notif.wanderer_source_hint.unverifiable': '"{tour}" ใน {trip} ตรวจใน wanderer ไม่ได้อีกต่อไป', + 'notif.action.view_tour': 'ดูทัวร์', 'notif.action.view': 'ดู', 'notif.action.accept': 'ยอมรับ', 'notif.action.decline': 'ปฏิเสธ', diff --git a/shared/src/i18n/th/settings.ts b/shared/src/i18n/th/settings.ts index 0878e62e4..9463e2137 100644 --- a/shared/src/i18n/th/settings.ts +++ b/shared/src/i18n/th/settings.ts @@ -106,6 +106,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'รูปภาพที่แชร์ (อิมมิช)', 'settings.notifyCollabMessage': 'ข้อความแชท (ร่วมกัน)', 'settings.notifyPackingTagged': 'รายการจัดกระเป๋า: มีการมอบหมายรายการให้คุณ', + 'settings.notifyWandererSourceHint': 'wanderer: ข้อสังเกตเกี่ยวกับเส้นทางของคุณ', 'settings.notifyWebhook': 'การแจ้งเตือนของเว็บฮุค', 'settings.notifyVersionAvailable': 'มีเวอร์ชันใหม่แล้ว', 'settings.notificationPreferences.email': 'อีเมล', diff --git a/shared/src/i18n/th/wandererWatch.ts b/shared/src/i18n/th/wandererWatch.ts new file mode 100644 index 000000000..7b9e274f3 --- /dev/null +++ b/shared/src/i18n/th/wandererWatch.ts @@ -0,0 +1,37 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'ตรวจการเปลี่ยนแปลงของเส้นทางของฉัน', + 'wanderer.watch.hint': 'แจ้งเตือนเมื่อเส้นทางที่นำเข้าอาจมีการเปลี่ยนแปลง', + 'wanderer.watch.info': + 'TREK อ่านบันทึกสั้น ๆ ของแต่ละเส้นทางที่คุณนำเข้าอย่างมากวันละครั้ง และไม่อ่านไฟล์ GPX เลย TREK แค่แจ้งเตือน ส่วนจะรับการเปลี่ยนแปลงหรือไม่ คุณตัดสินใจเองผ่านการตรวจแหล่งที่มาของทัวร์', + 'wanderer.watch.notConnected': 'เชื่อมต่อ wanderer ก่อน', + 'wanderer.watch.on': 'เปิดการตรวจเบื้องหลังแล้ว', + 'wanderer.watch.off': 'ปิดการตรวจเบื้องหลังแล้ว', + 'wanderer.watch.failed': 'สลับการตรวจเบื้องหลังไม่สำเร็จ', + 'wanderer.watch.lastRun': 'ตรวจล่าสุด {date}', + 'wanderer.watch.neverRun': 'ยังไม่ได้ตรวจ', + 'wanderer.watch.paused': 'หยุดชั่วคราวถึง {time}', + 'wanderer.watch.pausedHint': 'wanderer ไม่ตอบกลับ หลังความล้มเหลวแต่ละครั้ง TREK จะรอนานขึ้นก่อนถามอีกครั้ง', + 'wanderer.watch.blocked': 'โทเค็นถูกปฏิเสธ', + 'wanderer.watch.blockedHint': + 'wanderer ปฏิเสธโทเค็น API การตรวจจะทำต่อเมื่อการทดสอบการเชื่อมต่อผ่านหรือบันทึกโทเค็นใหม่', + 'wanderer.watch.hints': 'ข้อสังเกต', + 'wanderer.watch.hintsCount': 'ข้อสังเกตที่เปิดอยู่ {count} รายการ', + 'wanderer.watch.empty': 'ไม่มีข้อสังเกตที่เปิดอยู่', + 'wanderer.watch.dialogTitle': 'ข้อสังเกตเกี่ยวกับเส้นทางของคุณ', + 'wanderer.watch.openTour': 'เปิดทัวร์', + 'wanderer.watch.checkedAt': 'ตรวจเมื่อ {date}', + 'wanderer.watch.noticeLabel': 'การตรวจเบื้องหลัง', + 'wanderer.watch.state.possible_change': 'อาจมีการเปลี่ยนแปลง', + 'wanderer.watch.state.changed': 'เปลี่ยนแปลงแล้ว', + 'wanderer.watch.state.unverifiable': 'ตรวจไม่ได้', + 'wanderer.watch.stateHint.possible_change': + 'wanderer เปลี่ยนเวลาประทับของเส้นทาง มีเพียงการตรวจแหล่งที่มาที่บอกได้ว่าเนื้อหาต่างกันหรือไม่', + 'wanderer.watch.stateHint.changed': + 'บันทึกใน wanderer แสดงชื่อหรือคำอธิบายอื่น การตรวจแหล่งที่มาจะแสดงทุกอย่างที่เปลี่ยน', + 'wanderer.watch.stateHint.unverifiable': 'TREK ประเมินเส้นทางนี้ไม่ได้ จึงไม่บอกอะไรเกี่ยวกับการเปลี่ยนแปลง', + 'wanderer.watch.reason.unavailable': 'เข้าถึงเส้นทางไม่ได้', + 'wanderer.watch.reason.no_change_time': 'ไม่มีเวลาประทับ', +}; +export default wandererWatch; diff --git a/shared/src/i18n/tr/externalNotifications.ts b/shared/src/i18n/tr/externalNotifications.ts index d2ce837c3..ea8efea84 100644 --- a/shared/src/i18n/tr/externalNotifications.ts +++ b/shared/src/i18n/tr/externalNotifications.ts @@ -78,6 +78,15 @@ const tr: NotificationLocale = { title: 'Synology oturumu temizlendi', body: 'Synology hesabınız veya URL değişti. Synology Photos oturumunuz kapatıldı.', }), + wanderer_source_hint: (p) => ({ + title: `wanderer rotası: ${p.tour}`, + body: + p.state === 'changed' + ? `"${p.trip}" içindeki "${p.tour}" wanderer'da değiştirildi. Tüm değişiklikleri görmek için turu aç ve kaynağını denetle. Gezide hiçbir şey kendiliğinden değişmez.` + : p.state === 'unverifiable' + ? `"${p.trip}" içindeki "${p.tour}" artık wanderer'da denetlenemiyor. Gezindeki tur olduğu gibi kalır.` + : `"${p.trip}" içindeki "${p.tour}" wanderer'da değişmiş olabilir. Turu aç ve kaynağını denetle. Gezide hiçbir şey kendiliğinden değişmez.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/tr/index.ts b/shared/src/i18n/tr/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/tr/index.ts +++ b/shared/src/i18n/tr/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/tr/notif.ts b/shared/src/i18n/tr/notif.ts index fb4e39085..4ba1c41fd 100644 --- a/shared/src/i18n/tr/notif.ts +++ b/shared/src/i18n/tr/notif.ts @@ -41,6 +41,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': "Vacay'ı Görüntüle", 'notif.action.view_collection': 'Koleksiyonu görüntüle', 'notif.action.view_admin': 'Yöneticiye git', + 'notif.wanderer_source_hint.title': 'wanderer rotası', + 'notif.wanderer_source_hint.possible_change': '{trip} içindeki "{tour}" wanderer\'da değişmiş olabilir', + 'notif.wanderer_source_hint.changed': '{trip} içindeki "{tour}" wanderer\'da değiştirildi', + 'notif.wanderer_source_hint.unverifiable': '{trip} içindeki "{tour}" artık wanderer\'da denetlenemiyor', + 'notif.action.view_tour': 'Turu görüntüle', 'notif.action.view': 'Görüntüle', 'notif.action.accept': 'Kabul et', 'notif.action.decline': 'Reddet', diff --git a/shared/src/i18n/tr/settings.ts b/shared/src/i18n/tr/settings.ts index 7516b41b5..d7bd3a1ad 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -105,6 +105,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Paylaşılan fotoğraflar (Immich)', 'settings.notifyCollabMessage': 'Sohbet mesajları (Collab)', 'settings.notifyPackingTagged': 'Paket listesi: atamalar', + 'settings.notifyWandererSourceHint': 'wanderer: rotalarınla ilgili ipuçları', 'settings.notifyWebhook': 'Webhook bildirimleri', 'settings.notifyVersionAvailable': 'Yeni sürüm mevcut', 'settings.notifyReplicaFailure': 'Depolama kopyası hatası', diff --git a/shared/src/i18n/tr/wandererWatch.ts b/shared/src/i18n/tr/wandererWatch.ts new file mode 100644 index 000000000..c002eb8a4 --- /dev/null +++ b/shared/src/i18n/tr/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Rotalarımdaki değişiklikleri denetle', + 'wanderer.watch.hint': 'İçe aktarılan bir rota değişmiş olabilirse bildirim.', + 'wanderer.watch.info': + 'TREK, içe aktardığın her rotanın kısa kaydını günde en fazla bir kez okur, GPX dosyasını asla. Yalnızca bildirir: bir değişikliğin alınıp alınmayacağına turun kaynak denetimiyle sen karar verirsin.', + 'wanderer.watch.notConnected': 'Önce wanderer bağla', + 'wanderer.watch.on': 'Arka plan denetimi açıldı', + 'wanderer.watch.off': 'Arka plan denetimi kapatıldı', + 'wanderer.watch.failed': 'Arka plan denetimi değiştirilemedi', + 'wanderer.watch.lastRun': 'Son denetim {date}', + 'wanderer.watch.neverRun': 'Henüz denetlenmedi', + 'wanderer.watch.paused': '{time} saatine kadar duraklatıldı', + 'wanderer.watch.pausedHint': + 'wanderer yanıt vermedi. TREK her başarısızlıktan sonra yeniden sormadan önce daha uzun bekler.', + 'wanderer.watch.blocked': 'Belirteç reddedildi', + 'wanderer.watch.blockedHint': + 'wanderer API belirtecini reddetti. Bir bağlantı testi geçtiğinde ya da yeni bir belirteç kaydedildiğinde denetim sürer.', + 'wanderer.watch.hints': 'İpuçları', + 'wanderer.watch.hintsCount': '{count} açık ipucu', + 'wanderer.watch.hintsCount.one': '{count} açık ipucu', + 'wanderer.watch.empty': 'Açık ipucu yok', + 'wanderer.watch.dialogTitle': 'Rotalarınla ilgili ipuçları', + 'wanderer.watch.openTour': 'Turu aç', + 'wanderer.watch.checkedAt': 'Denetlendi {date}', + 'wanderer.watch.noticeLabel': 'Arka plan denetimi', + 'wanderer.watch.state.possible_change': 'Değişmiş olabilir', + 'wanderer.watch.state.changed': 'Değişti', + 'wanderer.watch.state.unverifiable': 'Denetlenemiyor', + 'wanderer.watch.stateHint.possible_change': + 'wanderer rotanın zaman damgasını değiştirdi. İçeriğin farklı olup olmadığını yalnızca kaynak denetimi gösterir.', + 'wanderer.watch.stateHint.changed': + 'wanderer kaydı başka bir ad ya da açıklama gösteriyor. Kaynak denetimi değişen her şeyi gösterir.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK bu rotayı değerlendiremedi, bu yüzden değişip değişmediği hakkında bir şey söylenmiyor.', + 'wanderer.watch.reason.unavailable': 'Rotaya ulaşılamıyor', + 'wanderer.watch.reason.no_change_time': 'Zaman damgası yok', +}; +export default wandererWatch; diff --git a/shared/src/i18n/uk/externalNotifications.ts b/shared/src/i18n/uk/externalNotifications.ts index 167610eef..d942d198b 100644 --- a/shared/src/i18n/uk/externalNotifications.ts +++ b/shared/src/i18n/uk/externalNotifications.ts @@ -79,6 +79,15 @@ const uk: NotificationLocale = { title: 'Сеанс Synology скинуто', body: 'Ваш обліковий запис або URL Synology змінився. Ви вийшли з Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Маршрут wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `«${p.tour}» у «${p.trip}» змінено в wanderer. Відкрийте тур і перевірте його джерело, щоб побачити всі зміни. У подорожі нічого не змінюється само.` + : p.state === 'unverifiable' + ? `«${p.tour}» у «${p.trip}» більше не можна перевірити в wanderer. Тур у вашій подорожі залишається як є.` + : `«${p.tour}» у «${p.trip}», можливо, змінено в wanderer. Відкрийте тур і перевірте його джерело. У подорожі нічого не змінюється само.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/uk/index.ts b/shared/src/i18n/uk/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/uk/index.ts +++ b/shared/src/i18n/uk/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/uk/notif.ts b/shared/src/i18n/uk/notif.ts index 9ad5fda4e..f2acac419 100644 --- a/shared/src/i18n/uk/notif.ts +++ b/shared/src/i18n/uk/notif.ts @@ -43,6 +43,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Відкрити Vacay', 'notif.action.view_collection': 'Переглянути колекцію', 'notif.action.view_admin': 'Перейти в адмін', + 'notif.wanderer_source_hint.title': 'Маршрут wanderer', + 'notif.wanderer_source_hint.possible_change': '«{tour}» у {trip}, можливо, змінено в wanderer', + 'notif.wanderer_source_hint.changed': '«{tour}» у {trip} змінено в wanderer', + 'notif.wanderer_source_hint.unverifiable': '«{tour}» у {trip} більше не можна перевірити в wanderer', + 'notif.action.view_tour': 'Переглянути тур', 'notif.action.view': 'Відкрити', 'notif.action.accept': 'Прийняти', 'notif.action.decline': 'Відхилити', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 73f4d3bae..cf8643221 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -104,6 +104,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Спільні фото (Immich)', 'settings.notifyCollabMessage': 'Повідомлення чату (Collab)', 'settings.notifyPackingTagged': 'Список речей: призначення', + 'settings.notifyWandererSourceHint': 'wanderer: підказки щодо ваших маршрутів', 'settings.notifyWebhook': 'Webhook-сповіщення', 'settings.notificationsDisabled': 'Сповіщення не налаштовані. Попросіть адміністратора ввімкнути сповіщення електронною поштою або webhook.', diff --git a/shared/src/i18n/uk/wandererWatch.ts b/shared/src/i18n/uk/wandererWatch.ts new file mode 100644 index 000000000..26ea319cf --- /dev/null +++ b/shared/src/i18n/uk/wandererWatch.ts @@ -0,0 +1,40 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Перевіряти зміни моїх маршрутів', + 'wanderer.watch.hint': 'Сповіщення, якщо імпортований маршрут міг змінитися.', + 'wanderer.watch.info': + 'Не частіше ніж раз на день TREK читає короткий запис кожного імпортованого маршруту, але ніколи його файл GPX. Він лише сповіщає: чи приймати зміну, вирішуєте ви самі через перевірку джерела туру.', + 'wanderer.watch.notConnected': 'Спершу підключіть wanderer', + 'wanderer.watch.on': 'Фонову перевірку увімкнено', + 'wanderer.watch.off': 'Фонову перевірку вимкнено', + 'wanderer.watch.failed': 'Не вдалося перемкнути фонову перевірку', + 'wanderer.watch.lastRun': 'Остання перевірка {date}', + 'wanderer.watch.neverRun': 'Ще не перевірялося', + 'wanderer.watch.paused': 'Призупинено до {time}', + 'wanderer.watch.pausedHint': 'wanderer не відповів. Після кожної невдачі TREK чекає довше, перш ніж запитати знову.', + 'wanderer.watch.blocked': 'Токен відхилено', + 'wanderer.watch.blockedHint': + 'wanderer відхилив API-токен. Перевірка продовжиться, коли пройде тест підключення або буде збережено новий токен.', + 'wanderer.watch.hints': 'Підказки', + 'wanderer.watch.hintsCount': '{count} відкритої підказки', + 'wanderer.watch.hintsCount.one': '{count} відкрита підказка', + 'wanderer.watch.hintsCount.few': '{count} відкриті підказки', + 'wanderer.watch.hintsCount.many': '{count} відкритих підказок', + 'wanderer.watch.empty': 'Відкритих підказок немає', + 'wanderer.watch.dialogTitle': 'Підказки щодо ваших маршрутів', + 'wanderer.watch.openTour': 'Відкрити тур', + 'wanderer.watch.checkedAt': 'Перевірено {date}', + 'wanderer.watch.noticeLabel': 'Фонова перевірка', + 'wanderer.watch.state.possible_change': 'Можливо, змінено', + 'wanderer.watch.state.changed': 'Змінено', + 'wanderer.watch.state.unverifiable': 'Не перевірити', + 'wanderer.watch.stateHint.possible_change': + 'wanderer змінив позначку часу маршруту. Лише перевірка джерела покаже, чи відрізняється вміст.', + 'wanderer.watch.stateHint.changed': + 'Запис у wanderer має іншу назву або опис. Перевірка джерела покаже все, що змінилося.', + 'wanderer.watch.stateHint.unverifiable': 'TREK не зміг оцінити цей маршрут, тож про зміни нічого не сказано.', + 'wanderer.watch.reason.unavailable': 'Маршрут недоступний', + 'wanderer.watch.reason.no_change_time': 'Немає позначки часу', +}; +export default wandererWatch; diff --git a/shared/src/i18n/vi/externalNotifications.ts b/shared/src/i18n/vi/externalNotifications.ts index e5fecb4e5..edde8ddff 100644 --- a/shared/src/i18n/vi/externalNotifications.ts +++ b/shared/src/i18n/vi/externalNotifications.ts @@ -68,6 +68,15 @@ const en: NotificationLocale = { title: 'Đã xóa phiên Synology', body: 'Tài khoản Synology của bạn hoặc URL đã thay đổi. Bạn đã đăng xuất khỏi Synology Photos.', }), + wanderer_source_hint: (p) => ({ + title: `Tuyến wanderer: ${p.tour}`, + body: + p.state === 'changed' + ? `“${p.tour}” trong “${p.trip}” đã được thay đổi trên wanderer. Hãy mở chuyến đi và kiểm tra nguồn để xem mọi thay đổi. Không có gì trong chuyến du lịch tự thay đổi.` + : p.state === 'unverifiable' + ? `“${p.tour}” trong “${p.trip}” không còn kiểm tra được trên wanderer. Chuyến đi trong chuyến du lịch của bạn vẫn giữ nguyên.` + : `“${p.tour}” trong “${p.trip}” có thể đã thay đổi trên wanderer. Hãy mở chuyến đi và kiểm tra nguồn của nó. Không có gì trong chuyến du lịch tự thay đổi.`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/vi/index.ts b/shared/src/i18n/vi/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/vi/index.ts +++ b/shared/src/i18n/vi/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/vi/notif.ts b/shared/src/i18n/vi/notif.ts index 1c22d3709..ad7aea0d2 100644 --- a/shared/src/i18n/vi/notif.ts +++ b/shared/src/i18n/vi/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': 'Xem kỳ nghỉ', 'notif.action.view_collection': 'Xem bộ sưu tập', 'notif.action.view_admin': 'Đi tới quản trị viên', + 'notif.wanderer_source_hint.title': 'Tuyến wanderer', + 'notif.wanderer_source_hint.possible_change': '“{tour}” trong {trip} có thể đã thay đổi trên wanderer', + 'notif.wanderer_source_hint.changed': '“{tour}” trong {trip} đã được thay đổi trên wanderer', + 'notif.wanderer_source_hint.unverifiable': '“{tour}” trong {trip} không còn kiểm tra được trên wanderer', + 'notif.action.view_tour': 'Xem chuyến đi', 'notif.action.view': 'Xem', 'notif.action.accept': 'Chấp nhận', 'notif.action.decline': 'Sự suy sụp', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 9ba571f1b..2bf783c1c 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -110,6 +110,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': 'Ảnh được chia sẻ (Immich)', 'settings.notifyCollabMessage': 'Tin nhắn trò chuyện (Cộng tác)', 'settings.notifyPackingTagged': 'Danh sách đóng gói: phân công', + 'settings.notifyWandererSourceHint': 'wanderer: gợi ý về các tuyến của bạn', 'settings.notifyWebhook': 'Thông báo webhook', 'settings.notifyVersionAvailable': 'Đã có phiên bản mới', 'settings.notifyReplicaFailure': 'Lỗi bản sao lưu trữ', diff --git a/shared/src/i18n/vi/wandererWatch.ts b/shared/src/i18n/vi/wandererWatch.ts new file mode 100644 index 000000000..6b98b1362 --- /dev/null +++ b/shared/src/i18n/vi/wandererWatch.ts @@ -0,0 +1,38 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': 'Kiểm tra thay đổi của các tuyến của tôi', + 'wanderer.watch.hint': 'Thông báo khi một tuyến đã nhập có thể đã thay đổi.', + 'wanderer.watch.info': + 'Tối đa mỗi ngày một lần, TREK đọc bản ghi ngắn của từng tuyến bạn đã nhập, không bao giờ đọc tệp GPX. TREK chỉ thông báo: nhận thay đổi hay không là do bạn quyết định qua việc kiểm tra nguồn của chuyến đi bộ.', + 'wanderer.watch.notConnected': 'Hãy kết nối wanderer trước', + 'wanderer.watch.on': 'Đã bật kiểm tra nền', + 'wanderer.watch.off': 'Đã tắt kiểm tra nền', + 'wanderer.watch.failed': 'Không thể chuyển kiểm tra nền', + 'wanderer.watch.lastRun': 'Kiểm tra lần cuối {date}', + 'wanderer.watch.neverRun': 'Chưa kiểm tra', + 'wanderer.watch.paused': 'Tạm dừng đến {time}', + 'wanderer.watch.pausedHint': 'wanderer không phản hồi. Sau mỗi lần thất bại, TREK chờ lâu hơn trước khi hỏi lại.', + 'wanderer.watch.blocked': 'Token bị từ chối', + 'wanderer.watch.blockedHint': + 'wanderer đã từ chối token API. Việc kiểm tra tiếp tục khi một lần kiểm tra kết nối thành công hoặc lưu token mới.', + 'wanderer.watch.hints': 'Gợi ý', + 'wanderer.watch.hintsCount': '{count} gợi ý đang mở', + 'wanderer.watch.empty': 'Không có gợi ý đang mở', + 'wanderer.watch.dialogTitle': 'Gợi ý về các tuyến của bạn', + 'wanderer.watch.openTour': 'Mở chuyến đi', + 'wanderer.watch.checkedAt': 'Đã kiểm tra {date}', + 'wanderer.watch.noticeLabel': 'Kiểm tra nền', + 'wanderer.watch.state.possible_change': 'Có thể đã thay đổi', + 'wanderer.watch.state.changed': 'Đã thay đổi', + 'wanderer.watch.state.unverifiable': 'Không kiểm tra được', + 'wanderer.watch.stateHint.possible_change': + 'wanderer đã đổi dấu thời gian của tuyến. Chỉ việc kiểm tra nguồn mới cho thấy nội dung có khác hay không.', + 'wanderer.watch.stateHint.changed': + 'Bản ghi trên wanderer có tên hoặc mô tả khác. Việc kiểm tra nguồn cho thấy mọi thứ đã thay đổi.', + 'wanderer.watch.stateHint.unverifiable': + 'TREK không thể đánh giá tuyến này nên không nói gì về việc nó có thay đổi hay không.', + 'wanderer.watch.reason.unavailable': 'Không truy cập được tuyến', + 'wanderer.watch.reason.no_change_time': 'Không có dấu thời gian', +}; +export default wandererWatch; diff --git a/shared/src/i18n/zh-TW/externalNotifications.ts b/shared/src/i18n/zh-TW/externalNotifications.ts index ef5c29e8f..073080327 100644 --- a/shared/src/i18n/zh-TW/externalNotifications.ts +++ b/shared/src/i18n/zh-TW/externalNotifications.ts @@ -69,6 +69,15 @@ const zhTW: NotificationLocale = { title: 'Synology 工作階段已清除', body: '您的 Synology 帳戶或 URL 已變更,您已登出 Synology Photos。', }), + wanderer_source_hint: (p) => ({ + title: `wanderer 路線: ${p.tour}`, + body: + p.state === 'changed' + ? `「${p.trip}」中的「${p.tour}」已在 wanderer 中變更。開啟行程並檢查其來源即可查看所有變更。旅行中的內容不會自行改變。` + : p.state === 'unverifiable' + ? `「${p.trip}」中的「${p.tour}」已無法在 wanderer 中檢查。你旅行中的行程保持不變。` + : `「${p.trip}」中的「${p.tour}」可能已在 wanderer 中變更。請開啟行程並檢查其來源。旅行中的內容不會自行改變。`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/zh-TW/index.ts b/shared/src/i18n/zh-TW/index.ts index 399d50692..9fe30b2af 100644 --- a/shared/src/i18n/zh-TW/index.ts +++ b/shared/src/i18n/zh-TW/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh-TW/notif.ts b/shared/src/i18n/zh-TW/notif.ts index 7c683fb6d..d65628691 100644 --- a/shared/src/i18n/zh-TW/notif.ts +++ b/shared/src/i18n/zh-TW/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': '查看 Vacay', 'notif.action.view_collection': '查看收藏', 'notif.action.view_admin': '前往管理', + 'notif.wanderer_source_hint.title': 'wanderer 路線', + 'notif.wanderer_source_hint.possible_change': '{trip} 中的「{tour}」可能已在 wanderer 中變更', + 'notif.wanderer_source_hint.changed': '{trip} 中的「{tour}」已在 wanderer 中變更', + 'notif.wanderer_source_hint.unverifiable': '{trip} 中的「{tour}」已無法在 wanderer 中檢查', + 'notif.action.view_tour': '查看行程', 'notif.action.view': '查看', 'notif.action.accept': '接受', 'notif.action.decline': '拒絕', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index ad974e679..5cb5659d7 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -99,6 +99,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyCollabMessage': '聊天訊息 (Collab)', 'settings.notifyPackingTagged': '行李清單:分配', + 'settings.notifyWandererSourceHint': 'wanderer:關於你的路線的提示', 'settings.notifyWebhook': 'Webhook 通知', 'settings.notifyVersionAvailable': '有新版本可用', 'settings.notifyReplicaFailure': '儲存複本故障', diff --git a/shared/src/i18n/zh-TW/wandererWatch.ts b/shared/src/i18n/zh-TW/wandererWatch.ts new file mode 100644 index 000000000..3a3cd1936 --- /dev/null +++ b/shared/src/i18n/zh-TW/wandererWatch.ts @@ -0,0 +1,33 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': '檢查我的路線是否有變更', + 'wanderer.watch.hint': '匯入的路線可能已變更時通知你。', + 'wanderer.watch.info': 'TREK 每天最多讀取一次你匯入的每條路線的簡短紀錄,從不讀取其 GPX 檔案。它只會通知你:是否採用變更,由你透過行程的來源檢查自行決定。', + 'wanderer.watch.notConnected': '請先連接 wanderer', + 'wanderer.watch.on': '背景檢查已開啟', + 'wanderer.watch.off': '背景檢查已關閉', + 'wanderer.watch.failed': '無法切換背景檢查', + 'wanderer.watch.lastRun': '上次檢查 {date}', + 'wanderer.watch.neverRun': '尚未檢查', + 'wanderer.watch.paused': '暫停至 {time}', + 'wanderer.watch.pausedHint': 'wanderer 沒有回應。每次失敗後,TREK 會等待更久再詢問。', + 'wanderer.watch.blocked': '權杖遭拒', + 'wanderer.watch.blockedHint': 'wanderer 拒絕了 API 權杖。連線測試通過或儲存新權杖後,檢查會繼續。', + 'wanderer.watch.hints': '提示', + 'wanderer.watch.hintsCount': '{count} 則未處理提示', + 'wanderer.watch.empty': '沒有未處理的提示', + 'wanderer.watch.dialogTitle': '關於你的路線的提示', + 'wanderer.watch.openTour': '開啟行程', + 'wanderer.watch.checkedAt': '檢查於 {date}', + 'wanderer.watch.noticeLabel': '背景檢查', + 'wanderer.watch.state.possible_change': '可能已變更', + 'wanderer.watch.state.changed': '已變更', + 'wanderer.watch.state.unverifiable': '無法檢查', + 'wanderer.watch.stateHint.possible_change': 'wanderer 更改了路線的時間戳記。內容是否不同,只有來源檢查才能看出。', + 'wanderer.watch.stateHint.changed': 'wanderer 的紀錄顯示了不同的名稱或描述。來源檢查會顯示所有變更。', + 'wanderer.watch.stateHint.unverifiable': 'TREK 無法判斷這條路線,因此對是否變更不作任何說明。', + 'wanderer.watch.reason.unavailable': '無法存取路線', + 'wanderer.watch.reason.no_change_time': '沒有時間戳記', +}; +export default wandererWatch; diff --git a/shared/src/i18n/zh/externalNotifications.ts b/shared/src/i18n/zh/externalNotifications.ts index 9049af8f3..9627fc73a 100644 --- a/shared/src/i18n/zh/externalNotifications.ts +++ b/shared/src/i18n/zh/externalNotifications.ts @@ -69,6 +69,15 @@ const zh: NotificationLocale = { title: 'Synology 会话已清除', body: '您的 Synology 账户或 URL 已更改,您已退出 Synology Photos。', }), + wanderer_source_hint: (p) => ({ + title: `wanderer 路线: ${p.tour}`, + body: + p.state === 'changed' + ? `“${p.trip}”中的“${p.tour}”已在 wanderer 中更改。打开行程并检查其来源即可查看所有变化。旅行中的内容不会自行改变。` + : p.state === 'unverifiable' + ? `“${p.trip}”中的“${p.tour}”已无法在 wanderer 中检查。你旅行中的行程保持不变。` + : `“${p.trip}”中的“${p.tour}”可能已在 wanderer 中变化。请打开行程并检查其来源。旅行中的内容不会自行改变。`, + }), plugin_notification: (p) => ({ title: p.title ?? '', body: p.body ?? '' }), }, passwordReset: { diff --git a/shared/src/i18n/zh/index.ts b/shared/src/i18n/zh/index.ts index 89ea7a355..ffe4ae567 100644 --- a/shared/src/i18n/zh/index.ts +++ b/shared/src/i18n/zh/index.ts @@ -55,6 +55,7 @@ import trips from './trips'; import undo from './undo'; import vacay from './vacay'; import wanderer from './wanderer'; +import wandererWatch from './wandererWatch'; const locale = { ...common, @@ -66,6 +67,7 @@ const locale = { ...tourCollection, ...tourPacking, ...wanderer, + ...wandererWatch, ...settings, ...admin, ...dayplan, diff --git a/shared/src/i18n/zh/notif.ts b/shared/src/i18n/zh/notif.ts index 97087fac5..21a0ef709 100644 --- a/shared/src/i18n/zh/notif.ts +++ b/shared/src/i18n/zh/notif.ts @@ -40,6 +40,11 @@ const notif: TranslationStrings = { 'notif.action.view_vacay': '查看假期', 'notif.action.view_collection': '查看收藏', 'notif.action.view_admin': '前往管理', + 'notif.wanderer_source_hint.title': 'wanderer 路线', + 'notif.wanderer_source_hint.possible_change': '{trip} 中的“{tour}”可能已在 wanderer 中变化', + 'notif.wanderer_source_hint.changed': '{trip} 中的“{tour}”已在 wanderer 中更改', + 'notif.wanderer_source_hint.unverifiable': '{trip} 中的“{tour}”已无法在 wanderer 中检查', + 'notif.action.view_tour': '查看行程', 'notif.action.view': '查看', 'notif.action.accept': '接受', 'notif.action.decline': '拒绝', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index b2d22c9be..f868a169f 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -98,6 +98,7 @@ const settings: TranslationStrings = { 'settings.notifyPhotosShared': '共享照片 (Immich)', 'settings.notifyCollabMessage': '聊天消息(协作)', 'settings.notifyPackingTagged': '行李清单:分配', + 'settings.notifyWandererSourceHint': 'wanderer:关于你的路线的提示', 'settings.notifyWebhook': 'Webhook 通知', 'settings.notificationsDisabled': '通知尚未配置。请联系管理员启用电子邮件或 Webhook 通知。', 'settings.notificationsActive': '活跃频道', diff --git a/shared/src/i18n/zh/wandererWatch.ts b/shared/src/i18n/zh/wandererWatch.ts new file mode 100644 index 000000000..c7515ec3c --- /dev/null +++ b/shared/src/i18n/zh/wandererWatch.ts @@ -0,0 +1,33 @@ +import type { TranslationStrings } from '../types'; + +const wandererWatch: TranslationStrings = { + 'wanderer.watch.label': '检查我的路线是否有变化', + 'wanderer.watch.hint': '导入的路线可能已变化时通知你。', + 'wanderer.watch.info': 'TREK 每天最多读取一次你导入的每条路线的简短记录,从不读取其 GPX 文件。它只会通知你:是否采纳变化,由你通过行程的来源检查自行决定。', + 'wanderer.watch.notConnected': '请先连接 wanderer', + 'wanderer.watch.on': '后台检查已开启', + 'wanderer.watch.off': '后台检查已关闭', + 'wanderer.watch.failed': '无法切换后台检查', + 'wanderer.watch.lastRun': '上次检查 {date}', + 'wanderer.watch.neverRun': '尚未检查', + 'wanderer.watch.paused': '暂停至 {time}', + 'wanderer.watch.pausedHint': 'wanderer 没有响应。每次失败后,TREK 会等待更久再询问。', + 'wanderer.watch.blocked': '令牌被拒绝', + 'wanderer.watch.blockedHint': 'wanderer 拒绝了 API 令牌。连接测试通过或保存新令牌后,检查会继续。', + 'wanderer.watch.hints': '提示', + 'wanderer.watch.hintsCount': '{count} 条未处理提示', + 'wanderer.watch.empty': '没有未处理的提示', + 'wanderer.watch.dialogTitle': '关于你的路线的提示', + 'wanderer.watch.openTour': '打开行程', + 'wanderer.watch.checkedAt': '检查于 {date}', + 'wanderer.watch.noticeLabel': '后台检查', + 'wanderer.watch.state.possible_change': '可能已变化', + 'wanderer.watch.state.changed': '已变化', + 'wanderer.watch.state.unverifiable': '无法检查', + 'wanderer.watch.stateHint.possible_change': 'wanderer 更改了路线的时间戳。内容是否不同,只有来源检查才能看出。', + 'wanderer.watch.stateHint.changed': 'wanderer 的记录显示了不同的名称或描述。来源检查会显示所有变化。', + 'wanderer.watch.stateHint.unverifiable': 'TREK 无法判断这条路线,因此对是否变化不作任何说明。', + 'wanderer.watch.reason.unavailable': '无法访问路线', + 'wanderer.watch.reason.no_change_time': '没有时间戳', +}; +export default wandererWatch; diff --git a/shared/src/index.ts b/shared/src/index.ts index 76e54bce6..6832d48d0 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -84,6 +84,7 @@ export * from './tours/paddle-check'; export * from './tours/route-corridor'; export * from './tours/horse-route.schema'; export * from './wanderer/wanderer.schema'; +export * from './wanderer/wanderer-watch.schema'; export * from './geo/haversine'; export * from './collection/collection.schema'; export * from './collection/collection-file.schema'; diff --git a/shared/src/wanderer/wanderer-watch.schema.spec.ts b/shared/src/wanderer/wanderer-watch.schema.spec.ts new file mode 100644 index 000000000..4b7f7dfe1 --- /dev/null +++ b/shared/src/wanderer/wanderer-watch.schema.spec.ts @@ -0,0 +1,51 @@ +import { + tourSourceHintResponseSchema, + tourSourceHintSchema, + wandererSourceHintItemSchema, + wandererWatchRequestSchema, + wandererWatchSchema, +} from './wanderer-watch.schema'; + +import { describe, expect, it } from 'vitest'; + +const hint = { + state: 'possible_change', + changes: [], + reason: null, + externalUpdated: '2026-10-14 08:00:00.000Z', + checkedAt: '2026-10-20T10:00:00Z', +}; + +describe('wanderer watch contract (WAN-02)', () => { + it('tells a possible change, a proven one and a source that could not be judged apart, and nothing else', () => { + expect(tourSourceHintSchema.parse(hint)).toEqual(hint); + expect(tourSourceHintSchema.safeParse({ ...hint, state: 'changed', changes: ['name', 'description'] }).success).toBe( + true, + ); + expect(tourSourceHintSchema.safeParse({ ...hint, state: 'unverifiable', reason: 'no_change_time' }).success).toBe( + true, + ); + expect(tourSourceHintSchema.safeParse({ ...hint, state: 'unchanged' }).success).toBe(false); + expect(tourSourceHintSchema.safeParse({ ...hint, changes: ['route'] }).success).toBe(false); + expect(tourSourceHintSchema.safeParse({ ...hint, reason: 'gone' }).success).toBe(false); + }); + + it('answers a tour with its hint or with none', () => { + expect(tourSourceHintResponseSchema.parse({ hint: null })).toEqual({ hint: null }); + expect(tourSourceHintResponseSchema.parse({ hint }).hint).toEqual(hint); + }); + + it('lists the hints with their tour and trip, beside the switch and how the last run went', () => { + const item = { ...hint, placeId: 7, tripId: 3, tripTitle: 'Alps', tourName: 'Ridge' }; + expect(wandererSourceHintItemSchema.parse(item)).toEqual(item); + expect(wandererSourceHintItemSchema.safeParse({ ...item, placeId: 0 }).success).toBe(false); + const watch = { enabled: true, connected: true, lastRunAt: null, pausedUntil: null, blocked: false, hints: [item] }; + expect(wandererWatchSchema.parse(watch)).toEqual(watch); + }); + + it('switches with a plain flag', () => { + expect(wandererWatchRequestSchema.parse({ enabled: true })).toEqual({ enabled: true }); + expect(wandererWatchRequestSchema.safeParse({ enabled: 'yes' }).success).toBe(false); + expect(wandererWatchRequestSchema.safeParse({}).success).toBe(false); + }); +}); diff --git a/shared/src/wanderer/wanderer-watch.schema.ts b/shared/src/wanderer/wanderer-watch.schema.ts new file mode 100644 index 000000000..eed496305 --- /dev/null +++ b/shared/src/wanderer/wanderer-watch.schema.ts @@ -0,0 +1,88 @@ +import { idSchema } from '../common/primitives.schema'; + +import { z } from 'zod'; + +/** + * WAN-02: the optional background watch over a user's own wanderer snapshots. + * + * Off until the user switches it on for their personal connection. While on, + * the server now and then reads the small record of each trail the user + * imported (never its GPX) and leaves a hint where the record suggests the + * trail moved on. A hint only ever points at the manual check of the source + * (MF-03) and from there at the confirmed reimport: nothing here changes, + * replaces or deletes a tour, and no answer is ever read as "unchanged". + */ + +/** + * What the watch found about one snapshot's source. + * + * - `possible_change`: the trail's change time moved since the user last saw + * it. A time is no proof of content, so only the manual check can tell. + * - `changed`: the trail's record itself differs in its name or description, + * which the record alone proves. Its line may differ too; that again only + * the manual check reads. + * - `unverifiable`: the watch could not judge the source, see the reason. + */ +export const tourSourceHintStateSchema = z.enum(['possible_change', 'changed', 'unverifiable']); +export type TourSourceHintState = z.infer; + +/** + * Why a source could not be judged: the trail is gone or no longer the + * user's own to read, or neither the import nor the trail carries a change + * time to compare. + */ +export const tourSourceHintReasonSchema = z.enum(['unavailable', 'no_change_time']); +export type TourSourceHintReason = z.infer; + +/** The parts of a trail its record alone can prove changed; the line needs the manual check. */ +export const tourSourceHintChangeSchema = z.enum(['name', 'description']); +export type TourSourceHintChange = z.infer; + +export const tourSourceHintSchema = z.object({ + state: tourSourceHintStateSchema, + /** For `changed`: what the record proves different. Empty for the other states. */ + changes: z.array(tourSourceHintChangeSchema), + /** For `unverifiable`: why. Null for the other states. */ + reason: tourSourceHintReasonSchema.nullable(), + /** The trail's change time as wanderer said it at the look; a hint, never a proof. */ + externalUpdated: z.string().nullable(), + /** When the watch last looked at this source. */ + checkedAt: z.string(), +}); +export type TourSourceHint = z.infer; + +/** The caller's own hint on one snapshot of a trip; null when there is none. */ +export const tourSourceHintResponseSchema = z.object({ + hint: tourSourceHintSchema.nullable(), +}); +export type TourSourceHintResponse = z.infer; + +/** A hint as the settings list shows it: with the tour and the trip it belongs to. */ +export const wandererSourceHintItemSchema = tourSourceHintSchema.extend({ + placeId: idSchema, + tripId: idSchema, + tripTitle: z.string(), + tourName: z.string(), +}); +export type WandererSourceHintItem = z.infer; + +export const wandererWatchSchema = z.object({ + /** The user switched the watch on for their connection. */ + enabled: z.boolean(), + /** There is a connection to watch with; without one the switch has nothing to act on. */ + connected: z.boolean(), + /** When the watch last finished a look through this connection. */ + lastRunAt: z.string().nullable(), + /** After failed looks the watch waits until then before it asks the instance again. */ + pausedUntil: z.string().nullable(), + /** wanderer refused the token: nothing is asked until a test of the connection passes again. */ + blocked: z.boolean(), + /** The open hints, newest look first. */ + hints: z.array(wandererSourceHintItemSchema), +}); +export type WandererWatch = z.infer; + +export const wandererWatchRequestSchema = z.object({ + enabled: z.boolean(), +}); +export type WandererWatchRequest = z.infer; diff --git a/wiki/Admin-Addons.md b/wiki/Admin-Addons.md index 62388399a..1d6a75b25 100644 --- a/wiki/Admin-Addons.md +++ b/wiki/Admin-Addons.md @@ -33,7 +33,7 @@ Each sub-toggle can be disabled independently while the parent addon remains ena **Road trip** plans a trip as one continuous drive: search along the route, driving limits, daily travel times, via points and avoidance of toll roads, motorways and ferries. **Disabled by default.** It needs no key: routing runs on the public OSRM and Valhalla servers until you set your own under **User Defaults** (**Own routing engine**, **Own Valhalla instance**, restart required). See [Road-Trip](Road-Trip). -**Tours** plans hikes and paddle tours (kayak, canoe, SUP) as tours of their own and adds them to the days of a trip. **Disabled by default.** Underneath its tile, the **wanderer** sub-toggle lets users connect their own wanderer instance and import its trails. +**Tours** plans hikes and paddle tours (kayak, canoe, SUP) as tours of their own and adds them to the days of a trip. **Disabled by default.** Underneath its tile, the **wanderer** sub-toggle lets users connect their own wanderer instance and import its trails. Each user can also switch on a background check of the trails they imported (Settings, Integrations, wanderer): TREK then reads the small record of each such trail at most once a day, never its GPX, and leaves a notification when the trail may have changed. It never changes a tour; taking a change in stays the user's own check and confirmed reimport. Switching wanderer off here stops those checks too. #### Paddle water data