From 2e1dca9fe9157bffd85959bd70191f63cc55530f Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 20 Oct 2026 14:40:50 +0200 Subject: [PATCH] feat(tours): let a background check hint at changes of your own wanderer trails Settings, Integrations, wanderer gains an optional background check of the trails you imported, off until you switch it on. While on, TREK reads the short record of each such trail at most once a day (never its GPX file) and leaves a hint when the trail may have moved on: "may have changed" when only wanderer's change time moved, "changed" when its name or description proves it, and "not checkable" when the trail is gone, no longer yours or carries no change time. Nothing is ever said to be unchanged. A new hint arrives as a notification that opens the tour (a new ?place= link in the planner); the tour's provenance shows it with the source check right beside it, and the card lists all open hints in a dialog, each leading to its tour. The check reads the source in full as before and spends the hint; taking a change in stays the confirmed reimport. No tour is ever changed or deleted. The check only asks through your own connection, only about trails your account imported into trips you can still open, and stops when you switch it off, remove the connection, wanderer refuses the token (until a test passes or a new token is saved) or the admin switches wanderer off; hints and snapshots stay. A run asks at most 25 connections for at most 10 records each, one after another, and an instance that fails (an outage, a rate limit) is left alone for an hour, doubling up to two days. Server: migration 046120 adds the switch and the run bookkeeping to wanderer_connections and the tour_source_hints table (one row per snapshot, for its importer, gone with it). WandererWatchService and an hourly wanderer-watch job on the CronRegistrar live in the tours domain and read through WandererService; GET and PUT /api/integrations/wanderer/watch and GET /api/trips/:tripId/tours/wanderer/sources/:placeId/hint (place_edit, like the check) answer with @trek/shared contracts. A new user notification event, wanderer_source_hint, can be muted per channel. Saving a new token now clears what the last check said about the old one. There is no MCP tool, as for the rest of the personal wanderer connection. All 27 locales carry the strings, and the admin wiki page says what the check does. --- client/src/api/wanderer.ts | 9 + .../Settings/WandererConnectionSection.tsx | 5 + .../Settings/WandererWatchRow.test.tsx | 101 ++++++ .../components/Settings/WandererWatchRow.tsx | 97 ++++++ .../components/Settings/notificationLabels.ts | 1 + .../Settings/useWandererWatch.test.ts | 134 ++++++++ .../components/Settings/useWandererWatch.ts | 113 ++++++ .../provenance/TourProvenanceSection.test.tsx | 49 ++- .../provenance/TourProvenanceSection.tsx | 107 ++++-- .../Tours/provenance/useSourceCheck.test.ts | 29 ++ .../Tours/provenance/useSourceCheck.ts | 32 +- .../Tours/provenance/useSourceHint.test.ts | 47 +++ .../Tours/provenance/useSourceHint.ts | 39 +++ .../Tours/wanderer/WandererHintsDialog.tsx | 119 +++++++ .../Tours/wanderer/sourceHintParts.test.tsx | 24 ++ .../Tours/wanderer/sourceHintParts.tsx | 63 ++++ .../tripPlanner/usePlaceDeepLink.test.ts | 44 +++ .../src/pages/tripPlanner/usePlaceDeepLink.ts | 34 ++ .../src/pages/tripPlanner/useTripPlanner.ts | 7 + client/src/repo/wandererRepo.test.ts | 21 ++ client/src/repo/wandererRepo.ts | 8 + client/tests/helpers/msw/handlers/tours.ts | 22 +- server/scripts/repository-owners.json | 1 + .../src/db/entities/TourSourceHints.entity.ts | 53 +++ server/src/db/entities/TourSources.entity.ts | 3 + server/src/db/entities/Users.entity.ts | 3 + .../db/entities/WandererConnections.entity.ts | 8 + server/src/db/entities/index.ts | 2 + server/src/db/json-columns.ts | 13 + server/src/db/kysely/TourSourceHints.table.ts | 15 + .../db/kysely/WandererConnections.table.ts | 4 + server/src/db/kysely/db.ts | 2 + ...nderer_connection_can_watch_its_sources.ts | 43 +++ .../TourSourceHints.repository.ts | 210 ++++++++++++ .../WandererConnections.repository.ts | 65 +++- .../nest/notifications/notification-events.ts | 2 + .../notifications/notifications.service.ts | 14 + .../notifications/transports/ntfy.service.ts | 1 + .../src/nest/tours/dto/wanderer-watch.dto.ts | 6 + .../nest/tours/tours-wanderer.controller.ts | 18 +- .../src/nest/tours/tours-wanderer.service.ts | 9 +- server/src/nest/tours/tours.helpers.ts | 17 + server/src/nest/tours/tours.module.ts | 18 +- .../nest/tours/wanderer-watch.controller.ts | 40 +++ .../src/nest/tours/wanderer-watch.helpers.ts | 74 ++++ server/src/nest/tours/wanderer-watch.job.ts | 32 ++ .../src/nest/tours/wanderer-watch.service.ts | 219 ++++++++++++ server/src/nest/wanderer/wanderer.service.ts | 131 ++++++- server/tests/e2e/wanderer.e2e.test.ts | 74 +++- server/tests/helpers/factories/tours.ts | 27 ++ server/tests/unit/db/entities-index.test.ts | 7 +- .../tests/unit/db/entity-class-fields.test.ts | 3 +- server/tests/unit/db/legacy-baseline.test.ts | 1 + .../TourSourceHints.repository.test.ts | 197 +++++++++++ .../WandererConnections.repository.test.ts | 62 ++++ .../notification-preferences.service.test.ts | 5 +- .../unit/nest/notifications.service.test.ts | 28 ++ .../nest/wanderer-watch.controller.test.ts | 79 +++++ .../unit/nest/wanderer-watch.helpers.test.ts | 124 +++++++ .../unit/nest/wanderer-watch.job.test.ts | 59 ++++ .../unit/nest/wanderer-watch.service.test.ts | 323 ++++++++++++++++++ .../tests/unit/nest/wanderer.service.test.ts | 107 +++++- shared/src/i18n/ar/externalNotifications.ts | 9 + shared/src/i18n/ar/index.ts | 2 + shared/src/i18n/ar/notif.ts | 5 + shared/src/i18n/ar/settings.ts | 1 + shared/src/i18n/ar/wandererWatch.ts | 40 +++ shared/src/i18n/az/externalNotifications.ts | 9 + shared/src/i18n/az/index.ts | 2 + shared/src/i18n/az/notif.ts | 5 + shared/src/i18n/az/settings.ts | 1 + shared/src/i18n/az/wandererWatch.ts | 40 +++ shared/src/i18n/br/externalNotifications.ts | 9 + shared/src/i18n/br/index.ts | 2 + shared/src/i18n/br/notif.ts | 5 + shared/src/i18n/br/settings.ts | 1 + shared/src/i18n/br/wandererWatch.ts | 40 +++ shared/src/i18n/ca/externalNotifications.ts | 9 + shared/src/i18n/ca/index.ts | 2 + shared/src/i18n/ca/notif.ts | 5 + shared/src/i18n/ca/settings.ts | 1 + shared/src/i18n/ca/wandererWatch.ts | 40 +++ shared/src/i18n/cs/externalNotifications.ts | 9 + shared/src/i18n/cs/index.ts | 2 + shared/src/i18n/cs/notif.ts | 5 + shared/src/i18n/cs/settings.ts | 1 + shared/src/i18n/cs/wandererWatch.ts | 39 +++ shared/src/i18n/de/externalNotifications.ts | 9 + shared/src/i18n/de/index.ts | 2 + shared/src/i18n/de/notif.ts | 5 + shared/src/i18n/de/settings.ts | 1 + shared/src/i18n/de/wandererWatch.ts | 40 +++ shared/src/i18n/en/externalNotifications.ts | 9 + shared/src/i18n/en/index.ts | 2 + shared/src/i18n/en/notif.ts | 5 + shared/src/i18n/en/settings.ts | 1 + shared/src/i18n/en/wandererWatch.ts | 39 +++ shared/src/i18n/es/externalNotifications.ts | 9 + shared/src/i18n/es/index.ts | 2 + shared/src/i18n/es/notif.ts | 5 + shared/src/i18n/es/settings.ts | 1 + shared/src/i18n/es/wandererWatch.ts | 38 +++ shared/src/i18n/et/externalNotifications.ts | 9 + shared/src/i18n/et/index.ts | 2 + shared/src/i18n/et/notif.ts | 5 + shared/src/i18n/et/settings.ts | 1 + shared/src/i18n/et/wandererWatch.ts | 39 +++ .../src/i18n/externalNotifications/types.ts | 1 + shared/src/i18n/fr/externalNotifications.ts | 9 + shared/src/i18n/fr/index.ts | 2 + shared/src/i18n/fr/notif.ts | 5 + shared/src/i18n/fr/settings.ts | 1 + shared/src/i18n/fr/wandererWatch.ts | 40 +++ shared/src/i18n/gr/externalNotifications.ts | 9 + shared/src/i18n/gr/index.ts | 2 + shared/src/i18n/gr/notif.ts | 5 + shared/src/i18n/gr/settings.ts | 1 + shared/src/i18n/gr/wandererWatch.ts | 40 +++ shared/src/i18n/hu/externalNotifications.ts | 9 + shared/src/i18n/hu/index.ts | 2 + shared/src/i18n/hu/notif.ts | 5 + shared/src/i18n/hu/settings.ts | 1 + shared/src/i18n/hu/wandererWatch.ts | 39 +++ shared/src/i18n/id/externalNotifications.ts | 9 + shared/src/i18n/id/index.ts | 2 + shared/src/i18n/id/notif.ts | 5 + shared/src/i18n/id/settings.ts | 1 + shared/src/i18n/id/wandererWatch.ts | 39 +++ shared/src/i18n/it/externalNotifications.ts | 9 + shared/src/i18n/it/index.ts | 2 + shared/src/i18n/it/notif.ts | 5 + shared/src/i18n/it/settings.ts | 1 + shared/src/i18n/it/wandererWatch.ts | 40 +++ shared/src/i18n/ja/externalNotifications.ts | 9 + shared/src/i18n/ja/index.ts | 2 + shared/src/i18n/ja/notif.ts | 5 + shared/src/i18n/ja/settings.ts | 1 + shared/src/i18n/ja/wandererWatch.ts | 34 ++ shared/src/i18n/ko/externalNotifications.ts | 9 + shared/src/i18n/ko/index.ts | 2 + shared/src/i18n/ko/notif.ts | 5 + shared/src/i18n/ko/settings.ts | 1 + shared/src/i18n/ko/wandererWatch.ts | 34 ++ shared/src/i18n/nl/externalNotifications.ts | 9 + shared/src/i18n/nl/index.ts | 2 + shared/src/i18n/nl/notif.ts | 5 + shared/src/i18n/nl/settings.ts | 1 + shared/src/i18n/nl/wandererWatch.ts | 40 +++ shared/src/i18n/pl/externalNotifications.ts | 9 + shared/src/i18n/pl/index.ts | 2 + shared/src/i18n/pl/notif.ts | 5 + shared/src/i18n/pl/settings.ts | 1 + shared/src/i18n/pl/wandererWatch.ts | 41 +++ shared/src/i18n/ru/externalNotifications.ts | 9 + shared/src/i18n/ru/index.ts | 2 + shared/src/i18n/ru/notif.ts | 5 + shared/src/i18n/ru/settings.ts | 1 + shared/src/i18n/ru/wandererWatch.ts | 41 +++ shared/src/i18n/sk/externalNotifications.ts | 9 + shared/src/i18n/sk/index.ts | 2 + shared/src/i18n/sk/notif.ts | 5 + shared/src/i18n/sk/settings.ts | 1 + shared/src/i18n/sk/wandererWatch.ts | 39 +++ shared/src/i18n/sv/externalNotifications.ts | 9 + shared/src/i18n/sv/index.ts | 2 + shared/src/i18n/sv/notif.ts | 5 + shared/src/i18n/sv/settings.ts | 1 + shared/src/i18n/sv/wandererWatch.ts | 39 +++ shared/src/i18n/th/externalNotifications.ts | 9 + shared/src/i18n/th/index.ts | 2 + shared/src/i18n/th/notif.ts | 5 + shared/src/i18n/th/settings.ts | 1 + shared/src/i18n/th/wandererWatch.ts | 37 ++ shared/src/i18n/tr/externalNotifications.ts | 9 + shared/src/i18n/tr/index.ts | 2 + shared/src/i18n/tr/notif.ts | 5 + shared/src/i18n/tr/settings.ts | 1 + shared/src/i18n/tr/wandererWatch.ts | 40 +++ shared/src/i18n/uk/externalNotifications.ts | 9 + shared/src/i18n/uk/index.ts | 2 + shared/src/i18n/uk/notif.ts | 5 + shared/src/i18n/uk/settings.ts | 1 + shared/src/i18n/uk/wandererWatch.ts | 40 +++ shared/src/i18n/vi/externalNotifications.ts | 9 + shared/src/i18n/vi/index.ts | 2 + shared/src/i18n/vi/notif.ts | 5 + shared/src/i18n/vi/settings.ts | 1 + shared/src/i18n/vi/wandererWatch.ts | 38 +++ .../src/i18n/zh-TW/externalNotifications.ts | 9 + shared/src/i18n/zh-TW/index.ts | 2 + shared/src/i18n/zh-TW/notif.ts | 5 + shared/src/i18n/zh-TW/settings.ts | 1 + shared/src/i18n/zh-TW/wandererWatch.ts | 33 ++ shared/src/i18n/zh/externalNotifications.ts | 9 + shared/src/i18n/zh/index.ts | 2 + shared/src/i18n/zh/notif.ts | 5 + shared/src/i18n/zh/settings.ts | 1 + shared/src/i18n/zh/wandererWatch.ts | 33 ++ shared/src/index.ts | 1 + .../wanderer/wanderer-watch.schema.spec.ts | 51 +++ shared/src/wanderer/wanderer-watch.schema.ts | 88 +++++ wiki/Admin-Addons.md | 2 +- 202 files changed, 4791 insertions(+), 57 deletions(-) create mode 100644 client/src/components/Settings/WandererWatchRow.test.tsx create mode 100644 client/src/components/Settings/WandererWatchRow.tsx create mode 100644 client/src/components/Settings/useWandererWatch.test.ts create mode 100644 client/src/components/Settings/useWandererWatch.ts create mode 100644 client/src/components/Tours/provenance/useSourceHint.test.ts create mode 100644 client/src/components/Tours/provenance/useSourceHint.ts create mode 100644 client/src/components/Tours/wanderer/WandererHintsDialog.tsx create mode 100644 client/src/components/Tours/wanderer/sourceHintParts.test.tsx create mode 100644 client/src/components/Tours/wanderer/sourceHintParts.tsx create mode 100644 client/src/pages/tripPlanner/usePlaceDeepLink.test.ts create mode 100644 client/src/pages/tripPlanner/usePlaceDeepLink.ts create mode 100644 server/src/db/entities/TourSourceHints.entity.ts create mode 100644 server/src/db/kysely/TourSourceHints.table.ts create mode 100644 server/src/db/migrations/Migration20200101046120_a_wanderer_connection_can_watch_its_sources.ts create mode 100644 server/src/db/repositories/TourSourceHints.repository.ts create mode 100644 server/src/nest/tours/dto/wanderer-watch.dto.ts create mode 100644 server/src/nest/tours/wanderer-watch.controller.ts create mode 100644 server/src/nest/tours/wanderer-watch.helpers.ts create mode 100644 server/src/nest/tours/wanderer-watch.job.ts create mode 100644 server/src/nest/tours/wanderer-watch.service.ts create mode 100644 server/tests/unit/db/repositories/TourSourceHints.repository.test.ts create mode 100644 server/tests/unit/nest/wanderer-watch.controller.test.ts create mode 100644 server/tests/unit/nest/wanderer-watch.helpers.test.ts create mode 100644 server/tests/unit/nest/wanderer-watch.job.test.ts create mode 100644 server/tests/unit/nest/wanderer-watch.service.test.ts create mode 100644 shared/src/i18n/ar/wandererWatch.ts create mode 100644 shared/src/i18n/az/wandererWatch.ts create mode 100644 shared/src/i18n/br/wandererWatch.ts create mode 100644 shared/src/i18n/ca/wandererWatch.ts create mode 100644 shared/src/i18n/cs/wandererWatch.ts create mode 100644 shared/src/i18n/de/wandererWatch.ts create mode 100644 shared/src/i18n/en/wandererWatch.ts create mode 100644 shared/src/i18n/es/wandererWatch.ts create mode 100644 shared/src/i18n/et/wandererWatch.ts create mode 100644 shared/src/i18n/fr/wandererWatch.ts create mode 100644 shared/src/i18n/gr/wandererWatch.ts create mode 100644 shared/src/i18n/hu/wandererWatch.ts create mode 100644 shared/src/i18n/id/wandererWatch.ts create mode 100644 shared/src/i18n/it/wandererWatch.ts create mode 100644 shared/src/i18n/ja/wandererWatch.ts create mode 100644 shared/src/i18n/ko/wandererWatch.ts create mode 100644 shared/src/i18n/nl/wandererWatch.ts create mode 100644 shared/src/i18n/pl/wandererWatch.ts create mode 100644 shared/src/i18n/ru/wandererWatch.ts create mode 100644 shared/src/i18n/sk/wandererWatch.ts create mode 100644 shared/src/i18n/sv/wandererWatch.ts create mode 100644 shared/src/i18n/th/wandererWatch.ts create mode 100644 shared/src/i18n/tr/wandererWatch.ts create mode 100644 shared/src/i18n/uk/wandererWatch.ts create mode 100644 shared/src/i18n/vi/wandererWatch.ts create mode 100644 shared/src/i18n/zh-TW/wandererWatch.ts create mode 100644 shared/src/i18n/zh/wandererWatch.ts create mode 100644 shared/src/wanderer/wanderer-watch.schema.spec.ts create mode 100644 shared/src/wanderer/wanderer-watch.schema.ts 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