diff --git a/.gitattributes b/.gitattributes
index b88429c87..36b00d92f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -26,3 +26,5 @@
*.eot binary
*.pdf binary
*.zip binary
+# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
+server/tests/fixtures/gpx/bom-crlf.gpx -text
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 6bd407d47..4d7a54e81 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -51,6 +51,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
+ # The image's start-up shape is pinned by a unit test
+ # (server/tests/unit/docker-entrypoint.test.ts), so a change to the
+ # Dockerfile or to the entrypoint script has to run the suite — that is
+ # exactly the change the test exists to catch.
+ - 'Dockerfile'
+ - 'server/scripts/**'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
@@ -69,6 +75,12 @@ on:
- 'package-lock.json'
- 'sonar-project.properties'
- '.github/workflows/test.yml'
+ # The image's start-up shape is pinned by a unit test
+ # (server/tests/unit/docker-entrypoint.test.ts), so a change to the
+ # Dockerfile or to the entrypoint script has to run the suite — that is
+ # exactly the change the test exists to catch.
+ - 'Dockerfile'
+ - 'server/scripts/**'
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
# neither the tests nor the scan — no test reads checked-in markdown
# and SonarCloud has no markdown analyzer. Negations must stay last.
diff --git a/Dockerfile b/Dockerfile
index d11fdb0dd..9a23437a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -62,7 +62,8 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
chown -R node:node /app
-# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
+# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses
+# it to drop to node.
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
ENV XDG_CACHE_HOME=/tmp/kf6-cache
@@ -117,9 +118,14 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
+# Start-up lives in a script, not an inline `sh -c` string: container management
+# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
+# carrying quotes does not survive that round-trip (the container then dies with
+# a shell syntax error before Node starts). A CMD of one bare token does.
+COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
+# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
+# node. chmod is explicit because a checkout on Windows carries no exec bit.
+RUN chmod 0755 /usr/local/bin/trek-entrypoint
+
ENTRYPOINT ["dumb-init", "--"]
-# Preflight: if the app code is missing, a volume was almost certainly mounted
-# over /app (it hides the image's node_modules + dist). Fail with actionable
-# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
-# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
-CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
+CMD ["/usr/local/bin/trek-entrypoint"]
diff --git a/charts/README.md b/charts/README.md
index 876d7b824..32f981f5e 100644
--- a/charts/README.md
+++ b/charts/README.md
@@ -49,6 +49,7 @@ See `values.yaml` for more options.
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
+- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml
index 3611650a0..65ee7d17c 100644
--- a/charts/trek/Chart.yaml
+++ b/charts/trek/Chart.yaml
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
-version: 4.2.0
+version: 4.3.0
description: Minimal Helm chart for TREK app
-appVersion: "4.2.0"
+appVersion: "4.3.0"
diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml
index 4e514acf3..a7397e76f 100644
--- a/charts/trek/templates/configmap.yaml
+++ b/charts/trek/templates/configmap.yaml
@@ -43,6 +43,9 @@ data:
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
{{- end }}
+ {{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
+ ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
+ {{- end }}
{{- if .Values.env.OIDC_ISSUER }}
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
{{- end }}
@@ -82,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 83996f19b..0d4bb1aeb 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -54,6 +54,9 @@ env:
# ALLOW_INTERNAL_NETWORK: "false"
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
+ # ALLOW_LINK_LOCAL_IPS: ""
+ # Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
+ # 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
# OIDC_ISSUER: ""
# OpenID Connect provider URL.
# OIDC_CLIENT_ID: ""
@@ -87,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.
@@ -114,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/package.json b/client/package.json
index 0cda4972f..ca4e6e836 100644
--- a/client/package.json
+++ b/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@trek/client",
- "version": "4.2.0",
+ "version": "4.3.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts
index 474845ec1..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-054
+// 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,6 +10,7 @@ import {
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
+ docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
} from './client'
interface Recorded { method: string; url: string; body: unknown }
@@ -665,6 +666,12 @@ describe('client > request payloads', () => {
expect(rec.method).toBe('DELETE')
expect(rec.body).toEqual({ password: 'hunter2' })
})
+
+ it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => {
+ const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway'))
+ expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes')
+ expect(rec.body).toEqual({ name: 'Norway' })
+ })
})
describe('client > query parameters', () => {
@@ -895,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 4f7a8ab63..4d5809557 100644
--- a/client/src/api/client.ts
+++ b/client/src/api/client.ts
@@ -1050,7 +1050,10 @@ const MEMORIES_TIMEOUT = 0
export const memoriesApi = {
status: (provider: string): Promise<{ connected: boolean }> =>
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
- search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
+ // utc_offset_minutes says which 24 hours from/to name. It is NOT the Synology
+ // `offset`, which is rows to skip: one body goes to whichever provider, so the
+ // two names have to stay apart (#2336).
+ search: (provider: string, body: { from: string; to: string; page: number; size: number; utc_offset_minutes?: number }, signal?: AbortSignal) =>
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
albums: (provider: string) =>
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
@@ -1358,6 +1361,64 @@ 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.
+ *
+ * Trip-scoped rather than user-scoped on purpose: a per-user connection would
+ * make a document's visibility depend on whose credentials fetched it, which is
+ * the opposite of what a shared trip needs.
+ */
+export const docsyncApi = {
+ providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data),
+ 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, 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 } : {}, ...docsyncUpstream }).then(r => r.data),
+ createScope: (tripId: number | string, connectionId: number, name: string) =>
+ 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, 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}`, docsyncUpstream).then(r => r.data),
+ syncNow: (tripId: number | string, linkId: number, full = false) =>
+ 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 }, docsyncRun).then(r => r.data),
+}
+
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
diff --git a/client/src/api/collections.test.ts b/client/src/api/collections.test.ts
index dd0ff7f34..65ca3e974 100644
--- a/client/src/api/collections.test.ts
+++ b/client/src/api/collections.test.ts
@@ -1,4 +1,4 @@
-// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
+// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034
//
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
// request-body shape that the server contract depends on. These tests drive each method
@@ -342,4 +342,23 @@ describe('collectionsApi', () => {
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
expect(res.changed).toBe(1)
})
+
+ it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => {
+ server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 })))
+
+ const res = await collectionsApi.exportGpx(1)
+
+ expect(requestUrl).toContain(`${BASE}/1/export/gpx`)
+ expect(res).toEqual({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 })
+ })
+
+ it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => {
+ const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] }
+ server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 })))
+
+ const res = await collectionsApi.readGpx({ gpx: '', file_name: 'tokyo.gpx' })
+
+ expect(requestBody).toEqual({ gpx: '', file_name: 'tokyo.gpx' })
+ expect(res).toEqual({ file, skipped: 0, track_points: 12 })
+ })
})
diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts
index 9ed4a56af..71f1ed8dd 100644
--- a/client/src/api/collections.ts
+++ b/client/src/api/collections.ts
@@ -12,7 +12,11 @@ import type {
CollectionImportablesResponse,
CollectionFile,
CollectionImportRequest,
+ CollectionImportIntoRequest,
CollectionImportResult,
+ CollectionGpxExport,
+ CollectionGpxReadRequest,
+ CollectionGpxReadResult,
CollectionPlaceUpdateRequest,
CollectionCopyToTripRequest,
CollectionInviteRequest,
@@ -62,6 +66,15 @@ export const collectionsApi = {
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
importFile: (body: CollectionImportRequest): Promise =>
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
+ /** The same file into a list that already exists: it only ever adds to it. */
+ importFileInto: (id: number, body: CollectionImportIntoRequest): Promise =>
+ ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data),
+ // The same list as GPX (#2301). A GPX is read by the server into a list file,
+ // which then goes through importFile above like any other.
+ exportGpx: (id: number): Promise =>
+ ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data),
+ readGpx: (body: CollectionGpxReadRequest): Promise =>
+ ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data),
// Both answer with the bare collection, not a { collection } envelope — the
// controller returns the service's `Collection` straight through, and the e2e
// suite pins that shape. Declaring the envelope made createCollection() resolve
diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts
index 268083586..504b8bd38 100644
--- a/client/src/api/wsEventPolicy.ts
+++ b/client/src/api/wsEventPolicy.ts
@@ -81,6 +81,12 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
+ // Document sync: components/Files/docsync/useDocSync re-reads the bindings
+ // and their counts, and components/Files/docsync/useDocSyncOffered asks
+ // again whether the Files screen offers the sync button. Content-free like
+ // the bag ping: what a run changed is per-binding server state, and the
+ // files it moved arrive as file:* events.
+ 'docsync:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx
index ad5f1ccb0..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-025
+// 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';
@@ -418,7 +418,102 @@ describe('AddonManager', () => {
expect(isOn(addonToggle('Todo List'))).toBe(true);
});
- it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
+ it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
+ server.use(addonsRoute([
+ buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
+ buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
+ buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
+ buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
+ ]));
+ render();
+
+ await screen.findByText('Paperless-ngx');
+ const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
+ expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
+ expect(isOn(subToggle('Paperless-ngx'))).toBe(true);
+ expect(isOn(subToggle('Nextcloud'))).toBe(false);
+ // Documents and Journey are the addons; an enabled provider is a shelf row
+ // and must not turn 1/2 into 2/2.
+ expect(screen.getByText('1/2')).toBeInTheDocument();
+ });
+
+ it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
+ server.use(addonsRoute([
+ buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
+ buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
+ ]));
+ render();
+
+ await screen.findByText('Documents');
+ expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
+ });
+
+ it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => {
+ const user = userEvent.setup();
+ let loads = 0;
+ let documentsOn = true;
+ let paperlessOn = true;
+ server.use(
+ http.get('/api/admin/addons', () => {
+ loads += 1;
+ return HttpResponse.json({
+ addons: [
+ buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }),
+ buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }),
+ ],
+ });
+ }),
+ http.put('/api/admin/addons/documents', async ({ request }) => {
+ documentsOn = (await request.json() as { enabled: boolean }).enabled;
+ if (!documentsOn) paperlessOn = false;
+ return HttpResponse.json({ success: true });
+ }),
+ );
+ render(<>>);
+ await screen.findByText('Paperless-ngx');
+
+ await user.click(addonToggle('Documents'));
+ await screen.findByText('Addon updated');
+ expect(loads).toBe(2);
+ expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
+
+ await user.click(addonToggle('Documents'));
+
+ await screen.findByText('Paperless-ngx');
+ expect(isOn(subToggle('Paperless-ngx'))).toBe(false);
+ });
+
+ it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => {
+ const user = userEvent.setup();
+ let loads = 0;
+ let body: unknown = null;
+ server.use(
+ http.get('/api/admin/addons', () => {
+ loads += 1;
+ return HttpResponse.json({
+ addons: [
+ buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
+ buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }),
+ ],
+ });
+ }),
+ http.put('/api/admin/addons/papra', async ({ request }) => {
+ body = await request.json();
+ return HttpResponse.json({ success: true });
+ }),
+ );
+ render(<>>);
+ await screen.findByText('Papra');
+
+ await user.click(subToggle('Papra'));
+
+ await screen.findByText('Addon updated');
+ expect(body).toEqual({ enabled: true });
+ expect(loads).toBe(1);
+ expect(isOn(subToggle('Papra'))).toBe(true);
+ });
+
+ it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
@@ -448,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(
+ ,
+ );
+
+ const links = await screen.findByText('Links');
+ expect(links).toHaveAttribute('title');
+ expect(links.getAttribute('title')).not.toBe('No shared links yet');
+ });
+
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render();
diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx
index 2deda0596..94755412a 100644
--- a/client/src/components/Admin/AddonManager.tsx
+++ b/client/src/components/Admin/AddonManager.tsx
@@ -9,6 +9,7 @@ import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
+import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -41,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
const PROVIDER_ICONS: Record> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
+ // The document providers' own marks, single-colour so they read as glyphs in
+ // a row of lucide icons and invert with the theme.
+ ...DOCUMENT_PROVIDER_ICONS,
}
interface Addon {
@@ -95,7 +99,7 @@ interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls
const COLLAB_SUB_FEATURES = [
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
- { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
+ { key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
] as const
@@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
- if (addon.id === 'journey') await loadAddons()
+ // Same for Documents: switching it off disables every document provider in
+ // the database, and the response carries only the addon itself.
+ if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
@@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return addon.type === 'photo_provider'
}
+ const isDocumentProviderAddon = (addon: Addon) => {
+ return addon.type === 'document_provider'
+ }
+
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
+ const documentProviderAddons = addons.filter(isDocumentProviderAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
@@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
+ const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
+ key: provider.id,
+ label: provider.name,
+ description: provider.description,
+ enabled: provider.enabled,
+ toggle: () => handleToggle(provider),
+ }))
if (loading) {
return (
@@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
+ // Document providers are the Documents tile's shelf, the way photo providers
+ // are Journey's. Unlike those they carry no credential form here: a document
+ // connection belongs to a trip, not to a user, so it is entered in the trip's
+ // file manager. The admin decides only whether a provider may be offered.
+ if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
+ return documentProviderOptions.map(provider => {
+ const ProviderIcon = PROVIDER_ICONS[provider.key]
+ return (
+ : undefined}
+ title={provider.label}
+ description={provider.description}
+ enabled={provider.enabled}
+ onToggle={provider.toggle}
+ />
+ )
+ })
+ }
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
@@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
- const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
+ const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
diff --git a/client/src/components/Collab/CollabChat.test.tsx b/client/src/components/Collab/CollabChat.test.tsx
index 2f818b142..2ed6657d6 100644
--- a/client/src/components/Collab/CollabChat.test.tsx
+++ b/client/src/components/Collab/CollabChat.test.tsx
@@ -708,4 +708,31 @@ describe('CollabChat', () => {
{ timeout: 3000 }
);
});
+
+ // The strip shows the four pictures that will go out; the two the cap turned away
+ // vanish without a word otherwise, and the sender believes all six were sent.
+ it('FE-COMP-CHAT-037: picking more images than fit on one message says so, and says it once', async () => {
+ const addToast = vi.fn();
+ window.__addToast = addToast as unknown as typeof window.__addToast;
+ let minted = 0;
+ const createObjURL = vi.spyOn(URL, 'createObjectURL').mockImplementation(() => `blob:mock/${++minted}`);
+ const revokeObjURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
+ try {
+ render();
+ await screen.findByText('Start the conversation');
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement;
+ const files = Array.from({ length: 6 }, (_, i) => new File([new Uint8Array(8)], `${i}.png`, { type: 'image/png' }));
+
+ fireEvent.change(input, { target: { files } });
+
+ await waitFor(() => expect(document.querySelectorAll('img[src^="blob:mock/"]')).toHaveLength(4));
+ expect(addToast).toHaveBeenCalledTimes(1);
+ const [message, type] = addToast.mock.calls[0];
+ expect(type).toBe('error');
+ expect(message).not.toBe('Only JPEG, PNG, GIF and WebP images up to 10 MB are allowed');
+ } finally {
+ delete window.__addToast;
+ createObjURL.mockRestore(); revokeObjURL.mockRestore();
+ }
+ });
});
diff --git a/client/src/components/Collab/CollabChat.tsx b/client/src/components/Collab/CollabChat.tsx
index f3cc80526..a1badfd38 100644
--- a/client/src/components/Collab/CollabChat.tsx
+++ b/client/src/components/Collab/CollabChat.tsx
@@ -48,8 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
)}
- {imagePreviews.length > 0 &&