diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml
index aeab34b07..a7397e76f 100644
--- a/charts/trek/templates/configmap.yaml
+++ b/charts/trek/templates/configmap.yaml
@@ -85,3 +85,15 @@ data:
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
{{- end }}
+ {{- /* Tested for presence rather than truth: the one value this switch gets set to is
+ false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
+ otherwise drop the key and leave the index on. */}}
+ {{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
+ TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
+ {{- end }}
+ {{- if .Values.env.TREK_PLACES_URL }}
+ TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
+ {{- end }}
+ {{- if .Values.env.AMAP_API_BASE }}
+ AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
+ {{- end }}
diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml
index 7b24b98dd..24e67325c 100644
--- a/charts/trek/templates/deployment.yaml
+++ b/charts/trek/templates/deployment.yaml
@@ -75,6 +75,18 @@ spec:
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
key: UNSPLASH_ACCESS_KEY
optional: true
+ - name: AMAP_API_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
+ key: AMAP_API_KEY
+ optional: true
+ - name: AMAP_API_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
+ key: AMAP_API_SECRET
+ optional: true
volumeMounts:
- name: data
mountPath: /app/data
diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml
index 08d81cabd..c465e8e34 100644
--- a/charts/trek/templates/secret.yaml
+++ b/charts/trek/templates/secret.yaml
@@ -20,6 +20,12 @@ data:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
{{- end }}
+ {{- if .Values.secretEnv.AMAP_API_KEY }}
+ AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
+ {{- end }}
+ {{- if .Values.secretEnv.AMAP_API_SECRET }}
+ AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
+ {{- end }}
{{- end }}
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
@@ -50,4 +56,10 @@ stringData:
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
{{- end }}
+ {{- if .Values.secretEnv.AMAP_API_KEY }}
+ AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
+ {{- end }}
+ {{- if .Values.secretEnv.AMAP_API_SECRET }}
+ AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
+ {{- end }}
{{- end }}
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index 69c136e04..0d4bb1aeb 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -90,8 +90,24 @@ env:
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
- # OVERPASS_TIMEOUT_MS: "12000"
- # Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000.
+ # OVERPASS_TIMEOUT_MS: "25000"
+ # Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
+ # lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
+ # Defaults to 25000.
+ # TREK_PLACES_ENABLED: "false"
+ # Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
+ # asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
+ # host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
+ # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
+ # here. Defaults to on.
+ # TREK_PLACES_URL: ""
+ # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
+ # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
+ # Defaults to https://places.liketrek.com.
+ # AMAP_API_BASE: ""
+ # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
+ # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
+ # Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
# Secret environment variables stored in a Kubernetes Secret.
@@ -117,6 +133,15 @@ secretEnv:
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
# in Admin > Settings; this value overrides that. Leave empty to disable.
UNSPLASH_ACCESS_KEY: ""
+ # Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
+ # China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
+ # a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
+ # key from the admin panel, or none.
+ AMAP_API_KEY: ""
+ # The private secret (数字签名) of that key. Required exactly when the key was created
+ # with signing enabled, because such a key rejects every unsigned request. Leave empty
+ # otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
+ AMAP_API_SECRET: ""
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
generateEncryptionKey: false
diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts
index eebc8f933..be5aee90b 100644
--- a/client/src/api/client.surface.test.ts
+++ b/client/src/api/client.surface.test.ts
@@ -1,4 +1,4 @@
-// FE-APISURF-001 to FE-APISURF-056
+// FE-APISURF-001 to FE-APISURF-057
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { AxiosResponse } from 'axios'
import { http, HttpResponse } from 'msw'
@@ -10,7 +10,7 @@ import {
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
- docsyncApi,
+ docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -902,4 +902,47 @@ describe('client > multipart uploads', () => {
expect(call[2]).toMatchObject({ timeout: 40000 })
}
})
+
+ it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => {
+ const post = spyPost()
+ const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
+ const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
+
+ // The server allows each request to a store 15 s, and a run waits for the
+ // whole listing and every transfer; cut off at 8 s the browser reported a
+ // failure while the server carried on and finished.
+ await docsyncApi.testConnection(1, { providerId: 'paperless' })
+ await docsyncApi.createScope(1, 5, 'Norway')
+ await docsyncApi.createLink(1, { connectionId: 5 })
+ await docsyncApi.listScopes(1, 5)
+ await docsyncApi.deleteLink(1, 9)
+
+ expect(post.mock.calls.map(c => c[0])).toEqual([
+ '/trips/1/docsync/connections/test',
+ '/trips/1/docsync/connections/5/scopes',
+ '/trips/1/docsync/links',
+ ])
+ for (const call of post.mock.calls) {
+ expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
+ }
+ expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes')
+ expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
+ expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9')
+ expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
+ expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000)
+
+ // A run, and a conflict choice that runs the binding afterwards.
+ post.mockClear()
+ await docsyncApi.syncNow(1, 9)
+ await docsyncApi.resolve(1, 4, 'trek')
+
+ expect(post.mock.calls.map(c => c[0])).toEqual([
+ '/trips/1/docsync/links/9/sync',
+ '/trips/1/docsync/items/4/resolve',
+ ])
+ for (const call of post.mock.calls) {
+ expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS })
+ }
+ expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS)
+ })
})
diff --git a/client/src/api/client.ts b/client/src/api/client.ts
index c033fc057..4d5809557 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -1361,6 +1361,34 @@ export const filesApi = {
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
+/**
+ * How long the browser waits for a document-sync call that makes the server go
+ * and ask the store.
+ *
+ * The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
+ * these, for the reason `dawarich.ts` gives: the server allows each request to
+ * a store 15 s by itself (20 s for Papra), a connection test may probe more
+ * than once, and binding or unbinding first registers or removes a webhook at
+ * the store. Cut off at 8 s, the browser reported a failure while the server
+ * carried on and finished, so a listing that took twelve seconds read as a
+ * broken store.
+ */
+export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000
+
+/**
+ * A run is longer again. The server answers only once the whole of it is
+ * through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s
+ * each, and Paperless-ngx holds every push until its consume task has run.
+ * Resolving a conflict runs the binding afterwards, so it waits just as long.
+ * Ten minutes covers a full transfer budget of ordinary documents; a run that
+ * outlasts it still finishes on the server, and the `docsync:changed` ping
+ * refreshes the dialog when it does.
+ */
+export const DOCSYNC_RUN_TIMEOUT_MS = 600_000
+
+const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }
+const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS }
+
/**
* Document sync: one provider connection per trip, shared by every member.
*
@@ -1373,22 +1401,22 @@ export const docsyncApi = {
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
- testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data),
+ testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data),
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
- apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data),
+ apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data),
createScope: (tripId: number | string, connectionId: number, name: string) =>
- apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }).then(r => r.data),
+ apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data),
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
- createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data),
+ createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data),
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
- deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data),
+ deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data),
syncNow: (tripId: number | string, linkId: number, full = false) =>
- apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data),
+ apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data),
items: (tripId: number | string, state?: string) =>
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
- apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data),
+ apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data),
}
export const reservationsApi = {
diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx
index 0964cedc0..1b86b373c 100644
--- a/client/src/components/Admin/AddonManager.test.tsx
+++ b/client/src/components/Admin/AddonManager.test.tsx
@@ -1,4 +1,4 @@
-// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-036
+// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037
import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { delay, http, HttpResponse } from 'msw';
@@ -543,6 +543,22 @@ describe('AddonManager', () => {
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
+ it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
+ // The row's tooltip is its description. The links tab's empty state read as a
+ // status here, and a status that never changes reads as a broken feature.
+ server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
+ render(
+