diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 341a06438..23615353c 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -208,3 +208,4 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
+ charts_url: https://chart.liketrek.com
diff --git a/README.md b/README.md
index 2f4fdcf8f..65477543f 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
-
+
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
-
-
-
+
+
+
@@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
```bash
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
- -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/TREK
+ -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
```
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
@@ -217,7 +217,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
```yaml
services:
app:
- image: mauriceboe/TREK:latest
+ image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
@@ -275,7 +275,7 @@ docker compose up -d
Helm (Kubernetes)
```bash
-helm repo add trek https://mauriceboe.github.io/TREK
+helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
@@ -305,9 +305,9 @@ docker compose pull && docker compose up -d
**Docker run** — reuse the original volume paths:
```bash
-docker pull mauriceboe/TREK
+docker pull mauriceboe/trek
docker rm -f trek
-docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/TREK
+docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
```
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
diff --git a/SECURITY.md b/SECURITY.md
index 59ba0a367..e2ce04147 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
## Scope
-This policy covers the TREK application and its Docker image (`mauriceboe/TREK`).
+This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
Third-party dependencies are monitored via GitHub Dependabot.
diff --git a/charts/README.md b/charts/README.md
index a54ec9b18..77feeaadf 100644
--- a/charts/README.md
+++ b/charts/README.md
@@ -15,11 +15,13 @@ This is a minimal Helm chart for deploying the TREK app.
A hosted Helm repository is available:
```sh
-helm repo add trek https://mauriceboe.github.io/TREK
+helm repo add trek https://chart.liketrek.com
helm repo update
helm install trek trek/trek
```
+> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
+
## Usage
Or install directly from the local chart:
diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml
index 0f50114f4..4af6bd9ba 100644
--- a/charts/trek/values.yaml
+++ b/charts/trek/values.yaml
@@ -1,6 +1,6 @@
image:
- repository: liketrek/TREK
+ repository: mauriceboe/trek
# tag: latest
pullPolicy: IfNotPresent
diff --git a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
index 50162a61d..78b74ead7 100644
--- a/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
+++ b/client/src/components/Journey/JourneyDetailPageGalleryView.tsx
@@ -194,7 +194,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
- {photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
+ {photo.provider === 'immich' ? 'Immich' : photo.provider === 'synologyphotos' ? 'Synology Photos' : photo.provider}
)}
diff --git a/client/src/components/Planner/AirTrailImportModal.test.tsx b/client/src/components/Planner/AirTrailImportModal.test.tsx
index 1f13587a2..af7a8a9fc 100644
--- a/client/src/components/Planner/AirTrailImportModal.test.tsx
+++ b/client/src/components/Planner/AirTrailImportModal.test.tsx
@@ -100,6 +100,15 @@ describe('AirTrailImportModal', () => {
);
});
+ it('FE-PLANNER-AIRTRAIL-011: opens after being mounted closed (#1602)', async () => {
+ // The modal stays mounted with isOpen=false until the AirTrail button is
+ // clicked — every hook must run on the closed renders too, or the open
+ // render throws "Rendered more hooks than during the previous render".
+ const { rerender } = render();
+ rerender();
+ expect(await screen.findByText('Import from AirTrail')).toBeInTheDocument();
+ });
+
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
render();
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
diff --git a/client/src/components/Planner/AirTrailImportModal.tsx b/client/src/components/Planner/AirTrailImportModal.tsx
index e1e9e69e3..c77d48044 100644
--- a/client/src/components/Planner/AirTrailImportModal.tsx
+++ b/client/src/components/Planner/AirTrailImportModal.tsx
@@ -142,6 +142,24 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [flights, trip?.start_date, trip?.end_date])
+ // A chain renders once as a group, at the position of its first listed leg —
+ // its other legs are swallowed wherever else they would appear. Computed per
+ // section up front so a chain straddling the trip range can't leave the other
+ // section holding nothing but its header.
+ const sectionItems = useMemo(() => {
+ const rendered = new Set()
+ const build = (list: AirtrailFlight[]) =>
+ list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
+ const chain = chainOf.get(f.id)
+ if (!chain) return [{ key: f.id, flight: f }]
+ const key = chain.map(c => c.id).join('+')
+ if (rendered.has(key)) return []
+ rendered.add(key)
+ return [{ key, chain }]
+ })
+ return { during: build(during), others: build(others) }
+ }, [during, others, chainOf])
+
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev)
@@ -286,24 +304,6 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
)
}
- // A chain renders once as a group, at the position of its first listed leg —
- // its other legs are swallowed wherever else they would appear. Computed per
- // section up front so a chain straddling the trip range can't leave the other
- // section holding nothing but its header.
- const sectionItems = useMemo(() => {
- const rendered = new Set()
- const build = (list: AirtrailFlight[]) =>
- list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
- const chain = chainOf.get(f.id)
- if (!chain) return [{ key: f.id, flight: f }]
- const key = chain.map(c => c.id).join('+')
- if (rendered.has(key)) return []
- rendered.add(key)
- return [{ key, chain }]
- })
- return { during: build(during), others: build(others) }
- }, [during, others, chainOf])
-
const renderItem = (item: { chain?: AirtrailFlight[]; flight?: AirtrailFlight }) =>
item.chain ? renderChain(item.chain) : renderFlight(item.flight!)
diff --git a/client/src/components/Planner/TransitSearchPanel.test.tsx b/client/src/components/Planner/TransitSearchPanel.test.tsx
index e5b079407..fcb270ed3 100644
--- a/client/src/components/Planner/TransitSearchPanel.test.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.test.tsx
@@ -172,6 +172,22 @@ describe('TransitSearchPanel', () => {
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
})
+ it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
+ const user = userEvent.setup()
+ // MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
+ const later = { ...ITINERARY, startTime: '2025-06-01T07:10:00Z', endTime: '2025-06-01T07:40:00Z' }
+ transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, later] })
+ render()
+ await pickFromAndTo(user)
+ await user.click(screen.getByRole('button', { name: 'Arrive' }))
+ await user.click(screen.getByRole('button', { name: /^Search$/ }))
+ await screen.findByText(/09:10 – 09:40/)
+ const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
+ // The itinerary arriving closest to the requested arrival time leads.
+ expect(cards[0].textContent).toContain('09:40')
+ expect(cards[1].textContent).toContain('09:00')
+ })
+
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
const user = userEvent.setup()
render()
diff --git a/client/src/components/Planner/TransitSearchPanel.tsx b/client/src/components/Planner/TransitSearchPanel.tsx
index 0ea432546..a1cfce9e1 100644
--- a/client/src/components/Planner/TransitSearchPanel.tsx
+++ b/client/src/components/Planner/TransitSearchPanel.tsx
@@ -386,6 +386,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
to: { ...l.to, name: cleanStop(l.to.name) },
})),
}))
+ // MOTIS returns arrive-by results ascending with the deadline-adjacent
+ // connection last (#1479) — flip so the itinerary arriving closest to the
+ // requested time leads the list, mirroring depart-by.
+ if (arriveBy) cleaned.sort((a, b) => Date.parse(b.endTime) - Date.parse(a.endTime))
setItineraries(cleaned)
} catch {
toast.error(t('transit.searchError'))
diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx
index abef57e64..0c26835e4 100644
--- a/client/src/pages/JourneyDetailPage.test.tsx
+++ b/client/src/pages/JourneyDetailPage.test.tsx
@@ -2064,15 +2064,17 @@ describe('JourneyDetailPage', () => {
});
// ── FE-PAGE-JOURNEYDETAIL-093 ──────────────────────────────────────────
- describe('FE-PAGE-JOURNEYDETAIL-093: Gallery shows Synology badge for synology photos', () => {
- it('renders "Synology" badge on photos from synology provider', async () => {
+ describe('FE-PAGE-JOURNEYDETAIL-093: Gallery shows Synology badge for synologyphotos photos', () => {
+ // The real provider id is 'synologyphotos' (see server ADDON_IDS/seeds);
+ // this previously used 'synology', which masked the raw-id badge bug (#1611).
+ it('renders "Synology Photos" badge, not the raw provider id', async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const synologyEntry = {
...mockJourneyDetail.entries[0],
photos: [{
- id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null,
- asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
+ id: 201, entry_id: 10, photo_id: 201, provider: 'synologyphotos', file_path: null,
+ asset_id: '456_cachekey', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
};
@@ -2080,8 +2082,8 @@ describe('JourneyDetailPage', () => {
entries: [synologyEntry, mockJourneyDetail.entries[1]],
stats: { entries: 2, photos: 1, places: 2 },
gallery: [{
- id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
- asset_id: 'syn-456', owner_id: 1, thumbnail_path: null,
+ id: 201, journey_id: 1, photo_id: 201, provider: 'synologyphotos', file_path: null,
+ asset_id: '456_cachekey', owner_id: 1, thumbnail_path: null,
caption: null, sort_order: 0, width: 800, height: 600, shared: 1, created_at: now,
}],
});
@@ -2098,7 +2100,8 @@ describe('JourneyDetailPage', () => {
expect(screen.getByText(/1 photos/i)).toBeInTheDocument();
});
- expect(screen.getByText('Synology')).toBeInTheDocument();
+ expect(screen.getByText('Synology Photos')).toBeInTheDocument();
+ expect(screen.queryByText('synologyphotos')).not.toBeInTheDocument();
});
});
diff --git a/client/src/pages/admin/AdminUserModals.tsx b/client/src/pages/admin/AdminUserModals.tsx
index 39a2230e6..d74b4bea1 100644
--- a/client/src/pages/admin/AdminUserModals.tsx
+++ b/client/src/pages/admin/AdminUserModals.tsx
@@ -336,14 +336,14 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
}}
className="border border-gray-700 bg-gray-900 text-gray-100 dark:bg-gray-950"
>
- {`docker pull mauriceboe/TREK:latest
+ {`docker pull mauriceboe/trek:latest
docker stop trek && docker rm trek
docker run -d --name trek \\
-p 3000:3000 \\
-v /opt/trek/data:/app/data \\
-v /opt/trek/uploads:/app/uploads \\
--restart unless-stopped \\
- mauriceboe/TREK:latest`}
+ mauriceboe/trek:latest`}
)}
diff --git a/client/src/pages/atlas/atlasModel.test.ts b/client/src/pages/atlas/atlasModel.test.ts
index f00a7de12..d0a91ee38 100644
--- a/client/src/pages/atlas/atlasModel.test.ts
+++ b/client/src/pages/atlas/atlasModel.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { normalizeRegionName } from './atlasModel';
+import { A2_TO_A3, normalizeRegionName } from './atlasModel';
describe('normalizeRegionName', () => {
it('matches names that only differ by diacritics (Ile-de-France vs Île-de-France)', () => {
@@ -18,3 +18,22 @@ describe('normalizeRegionName', () => {
expect(normalizeRegionName('Bretagne')).not.toBe(normalizeRegionName('Brittany'));
});
});
+
+// Countries whose GeoJSON feature carries no usable ISO_A2 must be hardcoded in
+// A2_TO_A3 (see the comment above the table) or they get no map handlers at all.
+describe('A2_TO_A3 hardcoded entries (#1609)', () => {
+ it('maps Kosovo (XK → XKX)', () => {
+ expect(A2_TO_A3.XK).toBe('XKX');
+ });
+
+ it('resolves the shipped Kosovo feature (ADM0_A3=XKX, ISO_A2=null) to XK', () => {
+ // Mirrors the onEachFeature fallback in useAtlas.ts: reverse lookup by A3,
+ // then ISO_A2 — which is null for Kosovo in the bundled geoBoundaries data.
+ const feature = { properties: { ADM0_A3: 'XKX', ISO_A2: null as string | null } };
+ const a3 = feature.properties.ADM0_A3;
+ const a3ToA2Entry = Object.entries(A2_TO_A3).find(([, v]) => v === a3);
+ const isoA2 = feature.properties.ISO_A2;
+ const countryCode = a3ToA2Entry ? a3ToA2Entry[0] : (isoA2 && isoA2 !== '-99' ? isoA2 : null);
+ expect(countryCode).toBe('XK');
+ });
+});
diff --git a/client/src/pages/atlas/atlasModel.ts b/client/src/pages/atlas/atlasModel.ts
index 43f2cc483..460bb3021 100644
--- a/client/src/pages/atlas/atlasModel.ts
+++ b/client/src/pages/atlas/atlasModel.ts
@@ -71,10 +71,11 @@ export function countryCodeToFlag(code: string): string {
}
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
-// 1. Hardcoded entries below — REQUIRED for any country whose Natural Earth GeoJSON record
-// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
-// (see geoData useEffect below) skips '-99' features, so those countries MUST be
-// listed here or the atlas_country_options A3-fallback will silently fail.
+// 1. Hardcoded entries below — REQUIRED for any country whose GeoJSON record has no
+// usable ISO_A2: '-99' in Natural Earth data (e.g. France=FRA, Norway=NOR) or null
+// in the geoBoundaries bundle (Kosovo=XKX, a user-assigned ISO code, #1609). The
+// runtime augmentation loop (see geoData useEffect below) skips such features, so
+// those countries MUST be listed here or the A3 fallbacks will silently fail.
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
// that has a valid ISO_A2, covering territories not present below.
-export const A2_TO_A3: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
+export const A2_TO_A3: Record = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","XK":"XKX","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
diff --git a/client/src/utils/dayOrder.test.ts b/client/src/utils/dayOrder.test.ts
index 193d7c24f..082f44c9b 100644
--- a/client/src/utils/dayOrder.test.ts
+++ b/client/src/utils/dayOrder.test.ts
@@ -178,13 +178,13 @@ describe('shouldDrawMorningLeg', () => {
expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: true, time: '15:00' })).toBe(true)
})
- it('DRAWS on a check-in day when time info is missing (home-base default, no over-suppression)', () => {
- // Without a comparable place time or hotel check-in time, the arrival day stays a
- // home-base loop — only a place provably before check-in suppresses the leg.
+ it('does NOT draw on a check-in day when time info is missing (#1597)', () => {
+ // An un-timed first place on an arrival day ("Home" before driving out) cannot prove
+ // you were at the hotel yet, so no hotel → first-stop leg — mirroring the evening rule.
const bookends = { morning: into({ check_in: '15:00' }), morningIsSleptHere: false }
- expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: true, time: null })).toBe(true)
+ expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: true, time: null })).toBe(false)
const noCheckIn = { morning: into({ check_in: null }), morningIsSleptHere: false }
- expect(shouldDrawMorningLeg(noCheckIn, checkInDay, { isPlace: true, time: '19:00' })).toBe(true)
+ expect(shouldDrawMorningLeg(noCheckIn, checkInDay, { isPlace: true, time: '19:00' })).toBe(false)
})
it('does NOT draw on a check-in day for a transport arrival (not a place, #1321)', () => {
diff --git a/client/src/utils/dayOrder.ts b/client/src/utils/dayOrder.ts
index 5f8ae4b9b..cf2ce304b 100644
--- a/client/src/utils/dayOrder.ts
+++ b/client/src/utils/dayOrder.ts
@@ -58,11 +58,12 @@ export const getAccommodationAnchors = (
}
// Whether to draw the morning hotel → first-stop leg. It is a real drive when you slept in the
-// morning hotel (a normal home-base day). On that hotel's check-in day the hotel is your base
-// once you arrive, so the leg is still the default for a PLACE first stop — suppressed only when
-// that place is timed BEFORE check-in (an airport you reach before dropping your bags, #1465), or
-// when the first stop is a transport arrival (you flew in, weren't at the hotel yet, #1321).
-// Un-timed places keep the loop, avoiding over-suppression on ordinary arrival days.
+// morning hotel (a normal home-base day). On that hotel's check-in day you were traveling TO the
+// hotel, so the leg is drawn only when the first stop is a PLACE provably timed at/after check-in
+// (you dropped your bags first). A place before check-in (an airport you reach first, #1465), a
+// transport arrival (you flew in, weren't at the hotel yet, #1321), an un-timed place ("Home"
+// before driving out, #1597), or a missing check-in time all mean no leg — mirroring the
+// evening rule below.
export const shouldDrawMorningLeg = (
bookends: { morning?: Accommodation; morningIsSleptHere?: boolean },
day: Day,
@@ -73,7 +74,7 @@ export const shouldDrawMorningLeg = (
if (!m || m.start_day_id !== day.id || !firstStop?.isPlace) return false
const checkIn = parseTimeToMinutes(m.check_in)
const stop = parseTimeToMinutes(firstStop.time)
- return !(checkIn != null && stop != null && stop < checkIn)
+ return checkIn != null && stop != null && stop >= checkIn
}
// Mirror of shouldDrawMorningLeg for the last-stop → hotel evening leg. It is a real drive when
diff --git a/client/tests/integration/hooks/useRouteCalculation.test.ts b/client/tests/integration/hooks/useRouteCalculation.test.ts
index cb39d3ed3..2309cea74 100644
--- a/client/tests/integration/hooks/useRouteCalculation.test.ts
+++ b/client/tests/integration/hooks/useRouteCalculation.test.ts
@@ -295,15 +295,16 @@ describe('useRouteCalculation', () => {
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
- it('FE-HOOK-ROUTE-015: day-1 with no transport keeps the hotel → first-activity leg', async () => {
- // Guard against over-suppression: with no arrival transport, the check-in day is a
- // home-base loop and the hotel → first-stop leg must remain.
- const actA = buildPlace({ lat: 41.38, lng: 2.17 });
+ it('FE-HOOK-ROUTE-015: day-1 with a first activity timed after check-in keeps the hotel → first-activity leg', async () => {
+ // The check-in day is still a home-base loop when the first activity provably happens
+ // at/after check-in (you dropped your bags first) — the hotel → first-stop leg remains.
+ // Since #1597 the loop needs that time proof; un-timed activities draw no morning leg.
+ const actA = buildPlace({ lat: 41.38, lng: 2.17, place_time: '15:00' });
const actB = buildPlace({ lat: 41.40, lng: 2.19 });
const hotel = { lat: 41.39, lng: 2.16 };
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB });
- const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, place_lat: hotel.lat, place_lng: hotel.lng }];
+ const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_in: '14:00', place_lat: hotel.lat, place_lng: hotel.lng }];
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
useTripStore.setState({
assignments: store.assignments,
@@ -322,6 +323,47 @@ describe('useRouteCalculation', () => {
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
});
+ it('FE-HOOK-ROUTE-022: #1597 check-in day with an un-timed place and a transport starts at the place, not the hotel', async () => {
+ // Day 1 of a driving holiday: leave "Home" (no time set), cross by tunnel/ferry, and
+ // check into a hotel near the arrival port tonight. The hotel is only reached at the
+ // end of the day, so no hotel → Home leg may be drawn no matter the check-in time —
+ // the route starts at Home and still ends at the hotel.
+ const home = buildPlace({ lat: 52.48, lng: -1.90 }); // un-timed "Home"
+ const dep = { lat: 51.09, lng: 1.12 }; // Folkestone terminal
+ const arr = { lat: 50.94, lng: 1.81 }; // Calais terminal
+ const hotel = { lat: 50.95, lng: 1.85 };
+
+ const tunnel = {
+ id: 200, type: 'train', day_id: 1, end_day_id: 1, day_plan_position: 1,
+ endpoints: [
+ { role: 'from', lat: dep.lat, lng: dep.lng },
+ { role: 'to', lat: arr.lat, lng: arr.lng },
+ ],
+ };
+ const a1 = buildAssignment({ day_id: 1, order_index: 0, place: home });
+ const accommodations = [{ id: 1, start_day_id: 1, end_day_id: 2, check_in: '19:00', place_lat: hotel.lat, place_lng: hotel.lng }];
+ const store = { assignments: { '1': [a1] } } as unknown as TripStoreState;
+ useTripStore.setState({
+ assignments: store.assignments,
+ reservations: [tunnel],
+ days: [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }],
+ } as any);
+
+ const { result } = renderHook(() =>
+ useRouteCalculation(store, 1, true, 'driving', accommodations as any)
+ );
+
+ await act(async () => {});
+
+ const legs = (result.current.route ?? []).map(run => run.map(p => `${p[0]},${p[1]}`));
+ // No phantom morning bookend [hotel → Home].
+ expect(legs).not.toContainEqual([`${hotel.lat},${hotel.lng}`, `${home.lat},${home.lng}`]);
+ // The day starts at Home and drives to the departure terminal.
+ expect(result.current.route?.[0]?.[0]).toEqual([home.lat, home.lng]);
+ // The evening leg [arrival terminal → hotel] is still drawn.
+ expect(legs).toContainEqual([`${arr.lat},${arr.lng}`, `${hotel.lat},${hotel.lng}`]);
+ });
+
it('FE-HOOK-ROUTE-016: #1297 transfer day with no activities draws the hotel → hotel leg', async () => {
// Day 2 is a pure transfer: check out of hotel A (slept there last night) and into
// hotel B tonight, with no activities or transport. The map must still draw A → B.
diff --git a/docker-compose.yml b/docker-compose.yml
index ce86c05db..e59ccbc2a 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,6 @@
services:
app:
- image: mauriceboe/TREK:latest
+ image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
diff --git a/server/assets/atlas/admin0.geojson.gz b/server/assets/atlas/admin0.geojson.gz
index 156d3cce3..c36de0d73 100644
Binary files a/server/assets/atlas/admin0.geojson.gz and b/server/assets/atlas/admin0.geojson.gz differ
diff --git a/server/scripts/build-atlas-geo.mjs b/server/scripts/build-atlas-geo.mjs
index dd4e06c84..385f4bfdc 100644
--- a/server/scripts/build-atlas-geo.mjs
+++ b/server/scripts/build-atlas-geo.mjs
@@ -42,11 +42,12 @@ const CONCURRENCY = 8
const RETRIES = 3
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
-// Regional-Codes). Drives ADM1 enumeration (one gbOpen request per code; missing ones
-// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
-// `shapeGroup`). A complete map — not the client's curated ~180 — is what restores the
-// dropped territories (Greenland, Falklands, French Guiana, …).
-const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
+// Regional-Codes, plus user-assigned XKX/XK for Kosovo, which geoBoundaries ships but
+// the upstream ISO list omits — #1609). Drives ADM1 enumeration (one gbOpen request per
+// code; missing ones 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries
+// keys by alpha-3 `shapeGroup`). A complete map — not the client's curated ~180 — is
+// what restores the dropped territories (Greenland, Falklands, French Guiana, …).
+const A3_TO_A2 = {"ABW":"AW", "AFG":"AF", "AGO":"AO", "AIA":"AI", "ALA":"AX", "ALB":"AL", "AND":"AD", "ARE":"AE", "ARG":"AR", "ARM":"AM", "ASM":"AS", "ATA":"AQ", "ATF":"TF", "ATG":"AG", "AUS":"AU", "AUT":"AT", "AZE":"AZ", "BDI":"BI", "BEL":"BE", "BEN":"BJ", "BES":"BQ", "BFA":"BF", "BGD":"BD", "BGR":"BG", "BHR":"BH", "BHS":"BS", "BIH":"BA", "BLM":"BL", "BLR":"BY", "BLZ":"BZ", "BMU":"BM", "BOL":"BO", "BRA":"BR", "BRB":"BB", "BRN":"BN", "BTN":"BT", "BVT":"BV", "BWA":"BW", "CAF":"CF", "CAN":"CA", "CCK":"CC", "CHE":"CH", "CHL":"CL", "CHN":"CN", "CIV":"CI", "CMR":"CM", "COD":"CD", "COG":"CG", "COK":"CK", "COL":"CO", "COM":"KM", "CPV":"CV", "CRI":"CR", "CUB":"CU", "CUW":"CW", "CXR":"CX", "CYM":"KY", "CYP":"CY", "CZE":"CZ", "DEU":"DE", "DJI":"DJ", "DMA":"DM", "DNK":"DK", "DOM":"DO", "DZA":"DZ", "ECU":"EC", "EGY":"EG", "ERI":"ER", "ESH":"EH", "ESP":"ES", "EST":"EE", "ETH":"ET", "FIN":"FI", "FJI":"FJ", "FLK":"FK", "FRA":"FR", "FRO":"FO", "FSM":"FM", "GAB":"GA", "GBR":"GB", "GEO":"GE", "GGY":"GG", "GHA":"GH", "GIB":"GI", "GIN":"GN", "GLP":"GP", "GMB":"GM", "GNB":"GW", "GNQ":"GQ", "GRC":"GR", "GRD":"GD", "GRL":"GL", "GTM":"GT", "GUF":"GF", "GUM":"GU", "GUY":"GY", "HKG":"HK", "HMD":"HM", "HND":"HN", "HRV":"HR", "HTI":"HT", "HUN":"HU", "IDN":"ID", "IMN":"IM", "IND":"IN", "IOT":"IO", "IRL":"IE", "IRN":"IR", "IRQ":"IQ", "ISL":"IS", "ISR":"IL", "ITA":"IT", "JAM":"JM", "JEY":"JE", "JOR":"JO", "JPN":"JP", "KAZ":"KZ", "KEN":"KE", "KGZ":"KG", "KHM":"KH", "KIR":"KI", "KNA":"KN", "KOR":"KR", "KWT":"KW", "LAO":"LA", "LBN":"LB", "LBR":"LR", "LBY":"LY", "LCA":"LC", "LIE":"LI", "LKA":"LK", "LSO":"LS", "LTU":"LT", "LUX":"LU", "LVA":"LV", "MAC":"MO", "MAF":"MF", "MAR":"MA", "MCO":"MC", "MDA":"MD", "MDG":"MG", "MDV":"MV", "MEX":"MX", "MHL":"MH", "MKD":"MK", "MLI":"ML", "MLT":"MT", "MMR":"MM", "MNE":"ME", "MNG":"MN", "MNP":"MP", "MOZ":"MZ", "MRT":"MR", "MSR":"MS", "MTQ":"MQ", "MUS":"MU", "MWI":"MW", "MYS":"MY", "MYT":"YT", "NAM":"NA", "NCL":"NC", "NER":"NE", "NFK":"NF", "NGA":"NG", "NIC":"NI", "NIU":"NU", "NLD":"NL", "NOR":"NO", "NPL":"NP", "NRU":"NR", "NZL":"NZ", "OMN":"OM", "PAK":"PK", "PAN":"PA", "PCN":"PN", "PER":"PE", "PHL":"PH", "PLW":"PW", "PNG":"PG", "POL":"PL", "PRI":"PR", "PRK":"KP", "PRT":"PT", "PRY":"PY", "PSE":"PS", "PYF":"PF", "QAT":"QA", "REU":"RE", "ROU":"RO", "RUS":"RU", "RWA":"RW", "SAU":"SA", "SDN":"SD", "SEN":"SN", "SGP":"SG", "SGS":"GS", "SHN":"SH", "SJM":"SJ", "SLB":"SB", "SLE":"SL", "SLV":"SV", "SMR":"SM", "SOM":"SO", "SPM":"PM", "SRB":"RS", "SSD":"SS", "STP":"ST", "SUR":"SR", "SVK":"SK", "SVN":"SI", "SWE":"SE", "SWZ":"SZ", "SXM":"SX", "SYC":"SC", "SYR":"SY", "TCA":"TC", "TCD":"TD", "TGO":"TG", "THA":"TH", "TJK":"TJ", "TKL":"TK", "TKM":"TM", "TLS":"TL", "TON":"TO", "TTO":"TT", "TUN":"TN", "TUR":"TR", "TUV":"TV", "TWN":"TW", "TZA":"TZ", "UGA":"UG", "UKR":"UA", "UMI":"UM", "URY":"UY", "USA":"US", "UZB":"UZ", "VAT":"VA", "VCT":"VC", "VEN":"VE", "VGB":"VG", "VIR":"VI", "VNM":"VN", "VUT":"VU", "WLF":"WF", "WSM":"WS", "XKX":"XK", "YEM":"YE", "ZAF":"ZA", "ZMB":"ZM", "ZWE":"ZW"}
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts
index 4691c906f..6961e97c9 100644
--- a/server/src/nest/plugins/registry/registry.service.ts
+++ b/server/src/nest/plugins/registry/registry.service.ts
@@ -76,6 +76,13 @@ export interface RegistryEntry {
authorPublicKey?: string;
/** Release-asset downloads across all versions, aggregated by the registry's stats cron. */
downloadCount?: number | null;
+ /**
+ * Store cover image, resolved at build time by the registry's aggregate step:
+ * docs/screenshot.png at the latest commit, else the first README image that
+ * resolves there. Absent for entries published before this existed — browse
+ * and detail then construct the docs/screenshot.png URL themselves.
+ */
+ screenshotUrl?: string | null;
versions: RegistryVersion[];
}
interface Registry {
@@ -208,7 +215,7 @@ export class PluginRegistryService {
minTrekVersion: latest?.minTrekVersion ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
- screenshotUrl: latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null,
+ screenshotUrl: p.screenshotUrl ?? (latest ? rawFileUrl(p.repo, latest.commitSha, 'docs/screenshot.png') : null),
signed: !!p.authorPublicKey && !!latest?.signature,
authorPublicKey: p.authorPublicKey ?? null,
...this.hostCompat(p),
@@ -283,7 +290,7 @@ export class PluginRegistryService {
publishedAt: latest?.publishedAt ?? null,
requiredAddons: latest?.requiredAddons ?? [],
pluginDependencies: latest?.pluginDependencies ?? [],
- screenshotUrl: latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null,
+ screenshotUrl: entry.screenshotUrl ?? (latest ? rawFileUrl(entry.repo, latest.commitSha, 'docs/screenshot.png') : null),
signed: !!entry.authorPublicKey && !!latest?.signature,
authorPublicKey: entry.authorPublicKey ?? null,
...this.hostCompat(entry),
diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts
index 2d183257f..41ae76914 100644
--- a/server/src/services/memories/helpersService.ts
+++ b/server/src/services/memories/helpersService.ts
@@ -2,7 +2,7 @@ import { pipeline } from 'node:stream/promises';
import { Readable } from 'node:stream';
import { Response } from 'express';
import { canAccessTrip, db } from "../../db/database";
-import { safeFetch, SsrfBlockedError } from '../../utils/ssrfGuard';
+import { safeFetch, SsrfBlockedError, type SafeFetchOptions } from '../../utils/ssrfGuard';
import { decrypt_api_key } from '../apiKeyCrypto';
// helpers for handling return types
@@ -251,9 +251,9 @@ export function updateSyncTimeForAlbumLink(linkId: string): void {
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
}
-export async function pipeAsset(url: string, response: Response, headers?: Record, signal?: AbortSignal, defaultCacheControl?: string): Promise {
+export async function pipeAsset(url: string, response: Response, headers?: Record, signal?: AbortSignal, defaultCacheControl?: string, fetchOptions?: SafeFetchOptions): Promise {
try {
- const resp = await safeFetch(url, { headers, signal: signal as any });
+ const resp = await safeFetch(url, { headers, signal: signal as any }, fetchOptions);
response.status(resp.status);
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
@@ -284,6 +284,8 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor
if (error instanceof SsrfBlockedError) {
response.status(400).json({ error: error.message });
} else {
+ // Don't log the URL — it can carry a Synology _sid / passphrase.
+ console.error('pipeAsset: upstream fetch failed:', error);
response.status(500).json({ error: 'Failed to fetch asset' });
}
}
diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts
index 83b44402d..8e31f4953 100644
--- a/server/src/services/memories/synologyService.ts
+++ b/server/src/services/memories/synologyService.ts
@@ -638,12 +638,13 @@ export async function fetchSynologyThumbnailBytes(
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
try {
- const resp = await safeFetch(url);
+ const resp = await safeFetch(url, undefined, { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl });
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
const contentType = resp.headers.get('content-type') || 'image/jpeg';
const bytes = Buffer.from(await resp.arrayBuffer());
return { bytes, contentType };
- } catch {
+ } catch (error) {
+ console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error);
return { error: 'Proxy error', status: 502 };
}
}
@@ -700,6 +701,6 @@ export async function streamSynologyAsset(
if (passphrase) params.append('passphrase', passphrase);
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
- await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400')
+ await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl })
}
diff --git a/server/src/services/notifications.ts b/server/src/services/notifications.ts
index adc9d59e4..cd753b83b 100644
--- a/server/src/services/notifications.ts
+++ b/server/src/services/notifications.ts
@@ -492,16 +492,25 @@ export function getAdminNtfyConfig(): NtfyConfig {
}
/**
- * Resolve the ntfy POST URL from admin base config + user override.
- * Returns null if topic cannot be determined.
+ * Resolve the ntfy POST URL for a per-user send. The topic must come from the
+ * user's own config — the admin topic is reserved for admin-scoped sends
+ * (see resolveAdminNtfyUrl). Only the server falls back to the admin default.
+ * Returns null if the user has no topic.
*/
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
- const topic = userCfg?.topic || adminCfg.topic;
+ const topic = userCfg?.topic;
if (!topic) return null;
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
return `${base}/${encodeURIComponent(topic)}`;
}
+/** Resolve the ntfy POST URL for admin-scoped sends. Returns null if no admin topic. */
+export function resolveAdminNtfyUrl(adminCfg: NtfyConfig): string | null {
+ if (!adminCfg.topic) return null;
+ const base = (adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
+ return `${base}/${encodeURIComponent(adminCfg.topic)}`;
+}
+
function encodeHeaderValue(value: string): string {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xff) {
diff --git a/server/src/services/notifications/builtins.ts b/server/src/services/notifications/builtins.ts
index 8846e35bf..17c96024b 100644
--- a/server/src/services/notifications/builtins.ts
+++ b/server/src/services/notifications/builtins.ts
@@ -12,6 +12,7 @@ import {
getUserNtfyConfig,
getAdminNtfyConfig,
resolveNtfyUrl,
+ resolveAdminNtfyUrl,
} from '../notifications';
import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry';
@@ -92,7 +93,7 @@ const ntfyChannel: ExternalChannel = {
},
async sendGlobal(msg: ChannelMessage) {
const adminCfg = getAdminNtfyConfig();
- const url = resolveNtfyUrl(adminCfg, null);
+ const url = resolveAdminNtfyUrl(adminCfg);
if (!url) return false;
return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
},
diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts
index 9f0c260ee..ab9f162b0 100644
--- a/server/tests/integration/memories-synology.test.ts
+++ b/server/tests/integration/memories-synology.test.ts
@@ -192,6 +192,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
}
}),
safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch),
+ __fakeSynologyFetch: makeFakeSynologyFetch,
};
});
@@ -1249,3 +1250,84 @@ describe('trek_photos orphan cleanup (SYNO-091)', () => {
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
});
});
+
+// ── Skip-SSL forwarding on image-byte fetches (#1611) ─────────────────────────
+
+describe('Synology skip-SSL forwarding to image fetches (#1611)', () => {
+ // Earlier tests queue mock*Once responses on safeFetch that are not always
+ // fully consumed — reset to the shared fake so they can't leak in here.
+ beforeEach(async () => {
+ const guard = await import('../../src/utils/ssrfGuard') as any;
+ vi.mocked(safeFetch).mockReset();
+ vi.mocked(safeFetch).mockImplementation(guard.__fakeSynologyFetch);
+ });
+
+ // Unique asset id per run — the thumbnail disk cache keys on
+ // provider:asset:kind:owner and would otherwise serve a stale entry from a
+ // previous run instead of hitting safeFetch.
+ let assetSeq = 0;
+ const uniqueAssetId = () => `${Date.now()}${++assetSeq}_test1611`;
+
+ function createSynologyTrekPhoto(skipSsl: 0 | 1) {
+ const { user } = createUser(testDb);
+ setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
+ testDb.prepare('UPDATE users SET synology_skip_ssl = ? WHERE id = ?').run(skipSsl, user.id);
+ const assetId = uniqueAssetId();
+ const insert = testDb.prepare(
+ 'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
+ ).run('synologyphotos', assetId, user.id);
+ return { user, trekPhotoId: Number(insert.lastInsertRowid) };
+ }
+
+ function thumbnailFetchCalls() {
+ return vi.mocked(safeFetch).mock.calls.filter(call => String(call[0]).includes('SYNO.Foto.Thumbnail'));
+ }
+
+ it('SYNO-100 — thumbnail fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
+ const { user, trekPhotoId } = createSynologyTrekPhoto(1);
+ vi.mocked(safeFetch).mockClear();
+
+ const res = await request(app)
+ .get(`/api/photos/${trekPhotoId}/thumbnail`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ const calls = thumbnailFetchCalls();
+ expect(calls.length).toBeGreaterThan(0);
+ for (const call of calls) {
+ expect(call[2]).toMatchObject({ rejectUnauthorized: false });
+ }
+ });
+
+ it('SYNO-101 — original fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
+ const { user, trekPhotoId } = createSynologyTrekPhoto(1);
+ vi.mocked(safeFetch).mockClear();
+
+ const res = await request(app)
+ .get(`/api/photos/${trekPhotoId}/original`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ const calls = thumbnailFetchCalls();
+ expect(calls.length).toBeGreaterThan(0);
+ for (const call of calls) {
+ expect(call[2]).toMatchObject({ rejectUnauthorized: false });
+ }
+ });
+
+ it('SYNO-102 — image fetches verify TLS when skip-SSL is disabled', async () => {
+ const { user, trekPhotoId } = createSynologyTrekPhoto(0);
+ vi.mocked(safeFetch).mockClear();
+
+ const res = await request(app)
+ .get(`/api/photos/${trekPhotoId}/thumbnail`)
+ .set('Cookie', authCookie(user.id));
+
+ expect(res.status).toBe(200);
+ const calls = thumbnailFetchCalls();
+ expect(calls.length).toBeGreaterThan(0);
+ for (const call of calls) {
+ expect(call[2]?.rejectUnauthorized ?? true).toBe(true);
+ }
+ });
+});
diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts
index a84b2321e..7f9869bbf 100644
--- a/server/tests/integration/plugins/registry.test.ts
+++ b/server/tests/integration/plugins/registry.test.ts
@@ -136,6 +136,23 @@ describe('PluginRegistryService', () => {
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);
});
+ // The registry aggregate now resolves the cover at build time (docs/screenshot.png
+ // else the first resolving README image) and injects screenshotUrl. When present it
+ // must win over the docs/screenshot.png guess — that's what gives entries without a
+ // committed docs/screenshot.png a non-blank card.
+ it('browse prefers a build-injected screenshotUrl over the docs/screenshot.png guess', async () => {
+ const injected = 'https://raw.githubusercontent.com/acme/trek-flight/bbbb/docs/img1.png';
+ (REGISTRY.plugins[0] as { screenshotUrl?: string }).screenshotUrl = injected;
+ try {
+ const [entry] = await svc.browse();
+ expect(entry.screenshotUrl).toBe(injected);
+ const d = await svc.detail('flight-tracker');
+ expect(d.screenshotUrl).toBe(injected);
+ } finally {
+ delete (REGISTRY.plugins[0] as { screenshotUrl?: string }).screenshotUrl;
+ }
+ });
+
it('detail merges registry metadata with a live manifest preview', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({
diff --git a/server/tests/unit/services/atlasBundle.test.ts b/server/tests/unit/services/atlasBundle.test.ts
index ef3a469b1..d4ded7774 100644
--- a/server/tests/unit/services/atlasBundle.test.ts
+++ b/server/tests/unit/services/atlasBundle.test.ts
@@ -8,6 +8,24 @@ import zlib from 'zlib';
// "ESP", every Chinese "CHN", also CL/OM), which made marking one region light up the
// whole country (#1217). build-atlas-geo.mjs now synthesizes a unique per-region code
// for those; this asserts the shipped bundle actually carries distinct codes.
+// Countries with user-assigned ISO codes (Kosovo = XK/XKX) are absent from the
+// upstream ISO list the builder's A3_TO_A2 map was sourced from, which shipped
+// Kosovo with ISO_A2=null — unresolvable by the client, so it had no hover/click
+// handlers and never appeared in search (#1609). Guard that the country bundle
+// carries the alpha-2 code the client keys on.
+describe('Atlas admin0 country bundle (#1609)', () => {
+ const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin0.geojson.gz');
+ const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
+ properties: { ISO_A2: string | null; ADM0_A3: string; NAME?: string };
+ }[];
+
+ it('ATLAS-BUNDLE-003 — Kosovo ships with a resolvable ISO_A2', () => {
+ const kosovo = features.filter(f => f.properties.ADM0_A3 === 'XKX');
+ expect(kosovo.length, 'exactly one Kosovo feature').toBe(1);
+ expect(kosovo[0].properties.ISO_A2).toBe('XK');
+ });
+});
+
describe('Atlas admin1 region bundle (#1217)', () => {
const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
diff --git a/server/tests/unit/services/memoriesHelpers.test.ts b/server/tests/unit/services/memoriesHelpers.test.ts
index e37652e05..128337f01 100644
--- a/server/tests/unit/services/memoriesHelpers.test.ts
+++ b/server/tests/unit/services/memoriesHelpers.test.ts
@@ -216,3 +216,61 @@ describe('pipeAsset', () => {
expect(res.end).toHaveBeenCalled();
});
});
+
+// ── pipeAsset fetch options (#1611) ───────────────────────────────────────────
+
+describe('pipeAsset fetch options (#1611)', () => {
+ function mockResponse(overrides: Record = {}) {
+ return {
+ status: vi.fn().mockReturnThis(),
+ set: vi.fn().mockReturnThis(),
+ end: vi.fn(),
+ json: vi.fn(),
+ headersSent: false,
+ ...overrides,
+ } as any;
+ }
+
+ it('MEM-HELPERS-021: forwards fetchOptions to safeFetch', async () => {
+ mockSafeFetch.mockResolvedValue({
+ status: 200,
+ headers: { get: vi.fn(() => null) },
+ body: null,
+ });
+ const res = mockResponse();
+
+ await pipeAsset('https://example.com/asset', res, undefined, undefined, undefined, { rejectUnauthorized: false });
+
+ expect(mockSafeFetch).toHaveBeenCalledWith(
+ 'https://example.com/asset',
+ expect.anything(),
+ { rejectUnauthorized: false },
+ );
+ });
+
+ it('MEM-HELPERS-022: omitting fetchOptions leaves safeFetch options undefined', async () => {
+ mockSafeFetch.mockResolvedValue({
+ status: 200,
+ headers: { get: vi.fn(() => null) },
+ body: null,
+ });
+ const res = mockResponse();
+
+ await pipeAsset('https://example.com/asset', res);
+
+ expect(mockSafeFetch.mock.calls[0][2]).toBeUndefined();
+ });
+
+ it('MEM-HELPERS-023: logs the underlying error when responding 500', async () => {
+ const boom = new Error('unable to verify the first certificate');
+ mockSafeFetch.mockRejectedValue(boom);
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const res = mockResponse({ headersSent: false });
+
+ await pipeAsset('https://example.com/asset', res);
+
+ expect(res.status).toHaveBeenCalledWith(500);
+ expect(errorSpy).toHaveBeenCalledWith(expect.any(String), boom);
+ errorSpy.mockRestore();
+ });
+});
diff --git a/server/tests/unit/services/notificationService.test.ts b/server/tests/unit/services/notificationService.test.ts
index 2a7b45260..9efb7431f 100644
--- a/server/tests/unit/services/notificationService.test.ts
+++ b/server/tests/unit/services/notificationService.test.ts
@@ -527,7 +527,7 @@ describe('send() — ntfy channel dispatch', () => {
it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'ntfy');
- // No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null
+ // No ntfy_topic set — resolveNtfyUrl requires a user topic, so it returns null
fetchMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
@@ -536,6 +536,18 @@ describe('send() — ntfy channel dispatch', () => {
expect(ntfyCalls.length).toBe(0);
});
+ it('NTFY-SVCB-005 — ntfy does not fall back to admin topic when user has no topic (#1608)', async () => {
+ const { user } = createUser(testDb);
+ setAdminNtfyTopic();
+ setNotificationChannels(testDb, 'ntfy');
+
+ fetchMock.mockClear();
+ await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Oslo', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
+
+ const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
+ expect(ntfyCalls.length).toBe(0);
+ });
+
it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => {
createAdmin(testDb);
setAdminNtfyTopic();
diff --git a/server/tests/unit/services/notifications.test.ts b/server/tests/unit/services/notifications.test.ts
index 42f8ef031..7abb985e4 100644
--- a/server/tests/unit/services/notifications.test.ts
+++ b/server/tests/unit/services/notifications.test.ts
@@ -24,7 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
createPinnedDispatcher: vi.fn(() => ({})),
}));
-import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
+import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, resolveAdminNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { logError } from '../../../src/services/auditLog';
@@ -325,11 +325,16 @@ afterAll(() => vi.unstubAllGlobals());
describe('resolveNtfyUrl', () => {
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
- it('uses admin server + admin topic when no user config', () => {
- expect(resolveNtfyUrl(adminCfg, null)).toBe('https://ntfy.sh/admin-topic');
+ it('returns null when no user config, even when admin topic is set (#1608)', () => {
+ expect(resolveNtfyUrl(adminCfg, null)).toBeNull();
});
- it('uses user topic over admin topic', () => {
+ it('returns null when user config has no topic, even when admin topic is set (#1608)', () => {
+ const user: NtfyConfig = { server: 'https://ntfy.example.com', topic: null, token: null };
+ expect(resolveNtfyUrl(adminCfg, user)).toBeNull();
+ });
+
+ it('uses user topic with admin server fallback', () => {
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic');
});
@@ -340,18 +345,32 @@ describe('resolveNtfyUrl', () => {
});
it('strips trailing slash from server', () => {
- const admin: NtfyConfig = { server: 'https://ntfy.sh/', topic: 'alerts', token: null };
- expect(resolveNtfyUrl(admin, null)).toBe('https://ntfy.sh/alerts');
- });
-
- it('returns null when no topic in admin or user config', () => {
- const noTopic: NtfyConfig = { server: 'https://ntfy.sh', topic: null, token: null };
- expect(resolveNtfyUrl(noTopic, null)).toBeNull();
+ const user: NtfyConfig = { server: 'https://ntfy.example.com/', topic: 'alerts', token: null };
+ expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/alerts');
});
it('falls back to https://ntfy.sh when no server configured', () => {
- const noServer: NtfyConfig = { server: null, topic: 'my-topic', token: null };
- expect(resolveNtfyUrl(noServer, null)).toBe('https://ntfy.sh/my-topic');
+ const noServer: NtfyConfig = { server: null, topic: null, token: null };
+ const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
+ expect(resolveNtfyUrl(noServer, user)).toBe('https://ntfy.sh/my-topic');
+ });
+});
+
+describe('resolveAdminNtfyUrl', () => {
+ it('builds URL from admin topic and server', () => {
+ expect(resolveAdminNtfyUrl({ server: 'https://ntfy.example.com', topic: 'admin-topic', token: null })).toBe('https://ntfy.example.com/admin-topic');
+ });
+
+ it('returns null when no admin topic', () => {
+ expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh', topic: null, token: null })).toBeNull();
+ });
+
+ it('falls back to https://ntfy.sh when no server configured', () => {
+ expect(resolveAdminNtfyUrl({ server: null, topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
+ });
+
+ it('strips trailing slash from server', () => {
+ expect(resolveAdminNtfyUrl({ server: 'https://ntfy.sh/', topic: 'alerts', token: null })).toBe('https://ntfy.sh/alerts');
});
});
diff --git a/shared/src/i18n/ar/memories.ts b/shared/src/i18n/ar/memories.ts
index 65cf3bced..6f2b96a1b 100644
--- a/shared/src/i18n/ar/memories.ts
+++ b/shared/src/i18n/ar/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'صور',
- 'memories.notConnected': 'Immich غير متصل',
- 'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
+ 'memories.notConnected': '{provider_name} غير متصل',
+ 'memories.notConnectedHint': 'قم بتوصيل {provider_name} في الإعدادات لعرض صور رحلتك هنا.',
'memories.notConnectedMultipleHint':
'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
'memories.noPhotos': 'لم يتم العثور على صور',
- 'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
+ 'memories.noPhotosHint': 'لم يتم العثور على صور في {provider_name} لفترة هذه الرحلة.',
'memories.photosFound': 'صور',
'memories.fromOthers': 'من آخرين',
'memories.sharePhotos': 'مشاركة الصور',
@@ -29,20 +29,20 @@ const memories: TranslationStrings = {
'memories.testFirst': 'اختبر الاتصال أولاً',
'memories.connected': 'متصل',
'memories.disconnected': 'غير متصل',
- 'memories.connectionSuccess': 'تم الاتصال بـ Immich',
- 'memories.connectionError': 'تعذر الاتصال بـ Immich',
+ 'memories.connectionSuccess': 'تم الاتصال بـ {provider_name}',
+ 'memories.connectionError': 'تعذر الاتصال بـ {provider_name}',
'memories.saved': 'تم حفظ إعدادات {provider_name}',
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
'memories.addPhotos': 'إضافة صور',
'memories.linkAlbum': 'ربط ألبوم',
- 'memories.selectAlbum': 'اختيار ألبوم Immich',
+ 'memories.selectAlbum': 'اختيار ألبوم {provider_name}',
'memories.selectAlbumMultiple': 'اختيار ألبوم',
'memories.noAlbums': 'لم يتم العثور على ألبومات',
'memories.syncAlbum': 'مزامنة الألبوم',
'memories.unlinkAlbum': 'إلغاء الربط',
'memories.photos': 'صور',
- 'memories.selectPhotos': 'اختيار صور من Immich',
+ 'memories.selectPhotos': 'اختيار صور من {provider_name}',
'memories.selectPhotosMultiple': 'اختيار الصور',
'memories.selectHint': 'انقر على الصور لتحديدها.',
'memories.selected': 'محدد',
diff --git a/shared/src/i18n/br/memories.ts b/shared/src/i18n/br/memories.ts
index e66d69135..08cd336ec 100644
--- a/shared/src/i18n/br/memories.ts
+++ b/shared/src/i18n/br/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
- 'memories.notConnected': 'Immich não conectado',
- 'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
+ 'memories.notConnected': '{provider_name} não conectado',
+ 'memories.notConnectedHint': 'Conecte sua instância {provider_name} nas Configurações para ver suas fotos de viagem aqui.',
'memories.notConnectedMultipleHint':
'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
'memories.noDates': 'Adicione datas à sua viagem para carregar fotos.',
'memories.noPhotos': 'Nenhuma foto encontrada',
- 'memories.noPhotosHint': 'Nenhuma foto encontrada no Immich para o período desta viagem.',
+ 'memories.noPhotosHint': 'Nenhuma foto encontrada em {provider_name} para o período desta viagem.',
'memories.photosFound': 'fotos',
'memories.fromOthers': 'de outros',
'memories.sharePhotos': 'Compartilhar fotos',
@@ -29,21 +29,21 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Teste a conexão primeiro',
'memories.connected': 'Conectado',
'memories.disconnected': 'Não conectado',
- 'memories.connectionSuccess': 'Conectado ao Immich',
- 'memories.connectionError': 'Não foi possível conectar ao Immich',
+ 'memories.connectionSuccess': 'Conectado a {provider_name}',
+ 'memories.connectionError': 'Não foi possível conectar a {provider_name}',
'memories.saved': 'Configurações do {provider_name} salvas',
'memories.providerDisconnectedBanner':
'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
'memories.saveError': 'Não foi possível salvar as configurações de {provider_name}',
'memories.addPhotos': 'Adicionar fotos',
'memories.linkAlbum': 'Vincular álbum',
- 'memories.selectAlbum': 'Selecionar álbum do Immich',
+ 'memories.selectAlbum': 'Selecionar álbum de {provider_name}',
'memories.selectAlbumMultiple': 'Selecionar álbum',
'memories.noAlbums': 'Nenhum álbum encontrado',
'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos',
- 'memories.selectPhotos': 'Selecionar fotos do Immich',
+ 'memories.selectPhotos': 'Selecionar fotos de {provider_name}',
'memories.selectPhotosMultiple': 'Selecionar fotos',
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
'memories.selected': 'selecionadas',
diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts
index 9fa024b50..0e3f1931a 100644
--- a/shared/src/i18n/ca/admin.ts
+++ b/shared/src/i18n/ca/admin.ts
@@ -261,7 +261,7 @@ const admin: TranslationStrings = {
"La teva instància de TREK s'executa en Docker. Per actualitzar a {version}, executa les següents ordres al teu servidor:",
'admin.update.reloadHint': 'Recarrega la pàgina en uns segons.',
'admin.update.nonDockerText':
- 'Sembla que estàs executant una instal·lació nativa (no Docker). Segueix la guia de la documentació oficial per actualitzar manualment els binaris i aplicar les migracions de la base de dades.',
+ "Aquesta instància de TREK no s'executa amb Docker. Per actualitzar a {version}, torna a executar el mètode d'instal·lació o actualització que vas fer servir — per exemple, a Proxmox Community Scripts executa l'actualització des de la consola LXC:",
'admin.update.wikiLink': "Obre la guia d\\'actualització",
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
'admin.addons.catalog.memories.description': "Comparteix fotos de viatge a través de la teva instància d'Immich",
diff --git a/shared/src/i18n/ca/memories.ts b/shared/src/i18n/ca/memories.ts
index 51f5d1052..86b7eba68 100644
--- a/shared/src/i18n/ca/memories.ts
+++ b/shared/src/i18n/ca/memories.ts
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
- 'memories.notConnected': 'Immich no connectat',
+ 'memories.notConnected': '{provider_name} no connectat',
'memories.notConnectedHint':
- "Connecta la teva instància d'Immich a Configuració per veure les teves fotos de viatge aquí.",
+ "Connecta la teva instància de {provider_name} a Configuració per veure les teves fotos de viatge aquí.",
'memories.notConnectedMultipleHint':
"Connecta algun d'aquests proveïdors de fotos: {provider_names} a Configuració per poder afegir fotos a aquest viatge.",
'memories.noDates': 'Afegeix dates al teu viatge per carregar fotos.',
'memories.noPhotos': "No s'han trobat fotos",
- 'memories.noPhotosHint': "No s'han trobat fotos a Immich per al rang de dates d'aquest viatge.",
+ 'memories.noPhotosHint': "No s'han trobat fotos a {provider_name} per al rang de dates d'aquest viatge.",
'memories.photosFound': 'fotos',
'memories.fromOthers': "d'altres",
'memories.sharePhotos': 'Comparteix fotos',
@@ -30,8 +30,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Prova primer la connexió',
'memories.connected': 'Connectat',
'memories.disconnected': 'No connectat',
- 'memories.connectionSuccess': 'Connectat a Immich',
- 'memories.connectionError': "No s'ha pogut connectar a Immich",
+ 'memories.connectionSuccess': 'Connectat a {provider_name}',
+ 'memories.connectionError': "No s'ha pogut connectar a {provider_name}",
'memories.saved': 'Configuració de {provider_name} desada',
'memories.providerDisconnectedBanner':
"S'ha perdut la connexió amb {provider_name}. Torna a connectar a Configuració per veure les fotos.",
@@ -44,13 +44,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Totes les ubicacions',
'memories.addPhotos': 'Afegeix fotos',
'memories.linkAlbum': 'Vincula un àlbum',
- 'memories.selectAlbum': "Selecciona un àlbum d'Immich",
+ 'memories.selectAlbum': "Selecciona un àlbum de {provider_name}",
'memories.selectAlbumMultiple': 'Selecciona un àlbum',
'memories.noAlbums': "No s'han trobat àlbums",
'memories.syncAlbum': "Sincronitza l'àlbum",
'memories.unlinkAlbum': 'Desvincula',
'memories.photos': 'fotos',
- 'memories.selectPhotos': "Selecciona fotos d'Immich",
+ 'memories.selectPhotos': "Selecciona fotos de {provider_name}",
'memories.selectPhotosMultiple': 'Selecciona fotos',
'memories.selectHint': 'Toca les fotos per seleccionar-les.',
'memories.selected': 'seleccionat(s)',
diff --git a/shared/src/i18n/cs/memories.ts b/shared/src/i18n/cs/memories.ts
index 6a7ebbc77..d58d8a6a2 100644
--- a/shared/src/i18n/cs/memories.ts
+++ b/shared/src/i18n/cs/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotky',
- 'memories.notConnected': 'Immich není připojen',
- 'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
+ 'memories.notConnected': '{provider_name} není připojen',
+ 'memories.notConnectedHint': 'Připojte svoji instanci {provider_name} v Nastavení, abyste zde viděli fotky z cest.',
'memories.notConnectedMultipleHint':
'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.',
'memories.noDates': 'Přidejte data k cestě pro načtení fotek.',
'memories.noPhotos': 'Nenalezeny žádné fotky',
- 'memories.noPhotosHint': 'V Immich nebyly nalezeny žádné fotky pro období této cesty.',
+ 'memories.noPhotosHint': 'V {provider_name} nebyly nalezeny žádné fotky pro období této cesty.',
'memories.photosFound': 'fotek',
'memories.fromOthers': 'od ostatních',
'memories.sharePhotos': 'Sdílet fotky',
@@ -29,21 +29,21 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Nejprve otestujte připojení',
'memories.connected': 'Připojeno',
'memories.disconnected': 'Nepřipojeno',
- 'memories.connectionSuccess': 'Připojeno k Immich',
- 'memories.connectionError': 'Nepodařilo se připojit k Immich',
+ 'memories.connectionSuccess': 'Připojeno k {provider_name}',
+ 'memories.connectionError': 'Nepodařilo se připojit k {provider_name}',
'memories.saved': 'Nastavení {provider_name} uloženo',
'memories.providerDisconnectedBanner':
'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.',
'memories.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
'memories.addPhotos': 'Přidat fotky',
'memories.linkAlbum': 'Propojit album',
- 'memories.selectAlbum': 'Vybrat album z Immich',
+ 'memories.selectAlbum': 'Vybrat album z {provider_name}',
'memories.selectAlbumMultiple': 'Vybrat album',
'memories.noAlbums': 'Žádná alba nenalezena',
'memories.syncAlbum': 'Synchronizovat album',
'memories.unlinkAlbum': 'Odpojit',
'memories.photos': 'fotek',
- 'memories.selectPhotos': 'Vybrat fotky z Immich',
+ 'memories.selectPhotos': 'Vybrat fotky z {provider_name}',
'memories.selectPhotosMultiple': 'Vybrat fotky',
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
'memories.selected': 'vybráno',
diff --git a/shared/src/i18n/de/memories.ts b/shared/src/i18n/de/memories.ts
index c91fb0ade..ee8c8e687 100644
--- a/shared/src/i18n/de/memories.ts
+++ b/shared/src/i18n/de/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
- 'memories.notConnected': 'Immich nicht verbunden',
- 'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
+ 'memories.notConnected': '{provider_name} nicht verbunden',
+ 'memories.notConnectedHint': 'Verbinde deine {provider_name}-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
'memories.notConnectedMultipleHint':
'Verbinde einen dieser Fotoanbieter: {provider_names} in den Einstellungen, um Fotos zu dieser Reise hinzufügen zu können.',
'memories.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
'memories.noPhotos': 'Keine Fotos gefunden',
- 'memories.noPhotosHint': 'Keine Fotos in Immich für den Zeitraum dieser Reise gefunden.',
+ 'memories.noPhotosHint': 'Keine Fotos in {provider_name} für den Zeitraum dieser Reise gefunden.',
'memories.photosFound': 'Fotos',
'memories.fromOthers': 'von anderen',
'memories.sharePhotos': 'Fotos teilen',
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Verbindung zuerst testen',
'memories.connected': 'Verbunden',
'memories.disconnected': 'Nicht verbunden',
- 'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
- 'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
+ 'memories.connectionSuccess': 'Verbindung zu {provider_name} hergestellt',
+ 'memories.connectionError': 'Verbindung zu {provider_name} fehlgeschlagen',
'memories.saved': '{provider_name}-Einstellungen gespeichert',
'memories.providerDisconnectedBanner':
'Deine {provider_name}-Verbindung wurde getrennt. Verbinde erneut in den Einstellungen, um Fotos anzuzeigen.',
@@ -40,13 +40,13 @@ const memories: TranslationStrings = {
'memories.fillRequiredFields': 'Bitte füllen Sie alle Pflichtfelder aus',
'memories.addPhotos': 'Fotos hinzufügen',
'memories.linkAlbum': 'Album verknüpfen',
- 'memories.selectAlbum': 'Immich-Album auswählen',
+ 'memories.selectAlbum': '{provider_name}-Album auswählen',
'memories.selectAlbumMultiple': 'Album auswählen',
'memories.noAlbums': 'Keine Alben gefunden',
'memories.syncAlbum': 'Album synchronisieren',
'memories.unlinkAlbum': 'Album trennen',
'memories.photos': 'Fotos',
- 'memories.selectPhotos': 'Fotos aus Immich auswählen',
+ 'memories.selectPhotos': 'Fotos aus {provider_name} auswählen',
'memories.selectPhotosMultiple': 'Fotos auswählen',
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
'memories.selected': 'ausgewählt',
diff --git a/shared/src/i18n/es/memories.ts b/shared/src/i18n/es/memories.ts
index 0f050b888..09ad0c02a 100644
--- a/shared/src/i18n/es/memories.ts
+++ b/shared/src/i18n/es/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotos',
- 'memories.notConnected': 'Immich no conectado',
- 'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
+ 'memories.notConnected': '{provider_name} no conectado',
+ 'memories.notConnectedHint': 'Conecta tu instancia de {provider_name} en Ajustes para ver tus fotos de viaje aquí.',
'memories.notConnectedMultipleHint':
'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.',
'memories.noDates': 'Añade fechas a tu viaje para cargar fotos.',
'memories.noPhotos': 'No se encontraron fotos',
- 'memories.noPhotosHint': 'No se encontraron fotos en Immich para el rango de fechas de este viaje.',
+ 'memories.noPhotosHint': 'No se encontraron fotos en {provider_name} para el rango de fechas de este viaje.',
'memories.photosFound': 'fotos',
'memories.fromOthers': 'de otros',
'memories.sharePhotos': 'Compartir fotos',
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Probar conexión primero',
'memories.connected': 'Conectado',
'memories.disconnected': 'No conectado',
- 'memories.connectionSuccess': 'Conectado a Immich',
- 'memories.connectionError': 'No se pudo conectar a Immich',
+ 'memories.connectionSuccess': 'Conectado a {provider_name}',
+ 'memories.connectionError': 'No se pudo conectar a {provider_name}',
'memories.saved': 'Configuración de {provider_name} guardada',
'memories.providerDisconnectedBanner':
'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.',
@@ -43,13 +43,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Todas las ubicaciones',
'memories.addPhotos': 'Añadir fotos',
'memories.linkAlbum': 'Vincular álbum',
- 'memories.selectAlbum': 'Seleccionar álbum de Immich',
+ 'memories.selectAlbum': 'Seleccionar álbum de {provider_name}',
'memories.selectAlbumMultiple': 'Seleccionar álbum',
'memories.noAlbums': 'No se encontraron álbumes',
'memories.syncAlbum': 'Sincronizar álbum',
'memories.unlinkAlbum': 'Desvincular',
'memories.photos': 'fotos',
- 'memories.selectPhotos': 'Seleccionar fotos de Immich',
+ 'memories.selectPhotos': 'Seleccionar fotos de {provider_name}',
'memories.selectPhotosMultiple': 'Seleccionar fotos',
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
'memories.selected': 'seleccionado(s)',
diff --git a/shared/src/i18n/fr/memories.ts b/shared/src/i18n/fr/memories.ts
index 82e0a813f..2c3f661db 100644
--- a/shared/src/i18n/fr/memories.ts
+++ b/shared/src/i18n/fr/memories.ts
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Photos',
- 'memories.notConnected': 'Immich non connecté',
+ 'memories.notConnected': '{provider_name} non connecté',
'memories.notConnectedHint':
- 'Connectez votre instance Immich dans les paramètres pour voir vos photos de voyage ici.',
+ 'Connectez votre instance {provider_name} dans les paramètres pour voir vos photos de voyage ici.',
'memories.notConnectedMultipleHint':
'Connectez un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.',
'memories.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
'memories.noPhotos': 'Aucune photo trouvée',
- 'memories.noPhotosHint': 'Aucune photo trouvée dans Immich pour la période de ce voyage.',
+ 'memories.noPhotosHint': 'Aucune photo trouvée dans {provider_name} pour la période de ce voyage.',
'memories.photosFound': 'photos',
'memories.fromOthers': "d'autres",
'memories.sharePhotos': 'Partager les photos',
@@ -31,8 +31,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
'memories.connected': 'Connecté',
'memories.disconnected': 'Non connecté',
- 'memories.connectionSuccess': 'Connecté à Immich',
- 'memories.connectionError': 'Impossible de se connecter à Immich',
+ 'memories.connectionSuccess': 'Connecté à {provider_name}',
+ 'memories.connectionError': 'Impossible de se connecter à {provider_name}',
'memories.saved': 'Paramètres {provider_name} enregistrés',
'memories.providerDisconnectedBanner':
'Votre connexion {provider_name} est perdue. Reconnectez-vous dans les Paramètres pour voir les photos.',
@@ -45,13 +45,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Tous les lieux',
'memories.addPhotos': 'Ajouter des photos',
'memories.linkAlbum': 'Lier un album',
- 'memories.selectAlbum': 'Choisir un album Immich',
+ 'memories.selectAlbum': 'Choisir un album {provider_name}',
'memories.selectAlbumMultiple': 'Sélectionner un album',
'memories.noAlbums': 'Aucun album trouvé',
'memories.syncAlbum': 'Synchroniser',
'memories.unlinkAlbum': 'Délier',
'memories.photos': 'photos',
- 'memories.selectPhotos': 'Sélectionner des photos depuis Immich',
+ 'memories.selectPhotos': 'Sélectionner des photos depuis {provider_name}',
'memories.selectPhotosMultiple': 'Sélectionner des photos',
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
'memories.selected': 'sélectionné(s)',
diff --git a/shared/src/i18n/hu/memories.ts b/shared/src/i18n/hu/memories.ts
index 7c613c8c8..6ff3e2e9e 100644
--- a/shared/src/i18n/hu/memories.ts
+++ b/shared/src/i18n/hu/memories.ts
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Fotók',
- 'memories.notConnected': 'Immich nincs csatlakoztatva',
+ 'memories.notConnected': '{provider_name} nincs csatlakoztatva',
'memories.notConnectedHint':
- 'Csatlakoztasd az Immich példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.',
+ 'Csatlakoztasd a(z) {provider_name} példányodat a Beállításokban, hogy itt lásd az utazási fotóidat.',
'memories.notConnectedMultipleHint':
'A fényképek hozzáadásához csatlakoztasson egyet a következő fényképszolgáltatók közül a Beállításokban: {provider_names}.',
'memories.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.',
'memories.noPhotos': 'Nem találhatók fotók',
- 'memories.noPhotosHint': 'Nem találhatók fotók az Immichben erre az utazási időszakra.',
+ 'memories.noPhotosHint': 'Nem találhatók fotók a(z) {provider_name} szolgáltatásban erre az utazási időszakra.',
'memories.photosFound': 'fotó',
'memories.fromOthers': 'másoktól',
'memories.sharePhotos': 'Fotók megosztása',
@@ -31,21 +31,21 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Először teszteld a kapcsolatot',
'memories.connected': 'Csatlakoztatva',
'memories.disconnected': 'Nincs csatlakoztatva',
- 'memories.connectionSuccess': 'Csatlakozva az Immichhez',
- 'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
+ 'memories.connectionSuccess': 'Csatlakozva a(z) {provider_name} szolgáltatáshoz',
+ 'memories.connectionError': 'Nem sikerült csatlakozni a(z) {provider_name} szolgáltatáshoz',
'memories.saved': '{provider_name} beállítások mentve',
'memories.providerDisconnectedBanner':
'A {provider_name} kapcsolat megszakadt. Csatlakozzon újra a Beállításokban a fényképek megtekintéséhez.',
'memories.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait',
'memories.addPhotos': 'Fotók hozzáadása',
'memories.linkAlbum': 'Album csatolása',
- 'memories.selectAlbum': 'Immich album kiválasztása',
+ 'memories.selectAlbum': '{provider_name} album kiválasztása',
'memories.selectAlbumMultiple': 'Album kiválasztása',
'memories.noAlbums': 'Nem található album',
'memories.syncAlbum': 'Album szinkronizálása',
'memories.unlinkAlbum': 'Leválasztás',
'memories.photos': 'fotó',
- 'memories.selectPhotos': 'Fotók kiválasztása az Immichből',
+ 'memories.selectPhotos': 'Fotók kiválasztása a(z) {provider_name} szolgáltatásból',
'memories.selectPhotosMultiple': 'Fényképek kiválasztása',
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
'memories.selected': 'kijelölve',
diff --git a/shared/src/i18n/i18n-placeholders.spec.ts b/shared/src/i18n/i18n-placeholders.spec.ts
new file mode 100644
index 000000000..84dff6850
--- /dev/null
+++ b/shared/src/i18n/i18n-placeholders.spec.ts
@@ -0,0 +1,91 @@
+import ar from './ar';
+import br from './br';
+import ca from './ca';
+import cs from './cs';
+import de from './de';
+import en from './en';
+import es from './es';
+import fr from './fr';
+import gr from './gr';
+import hu from './hu';
+import id from './id';
+import itIT from './it';
+import ja from './ja';
+import ko from './ko';
+import nl from './nl';
+import pl from './pl';
+import ru from './ru';
+import sv from './sv';
+import tr from './tr';
+import type { TranslationStrings } from './types';
+import uk from './uk';
+import vi from './vi';
+import zh from './zh';
+import zhTW from './zh-TW';
+
+import { describe, it, expect } from 'vitest';
+
+/**
+ * Placeholder parity: every `{placeholder}` present in an EN string must also
+ * appear (untranslated) in each locale's translation of that key.
+ *
+ * This guards against the class of bug behind issue #1611's "Failed to connect
+ * to Immich" report: locales that hardcoded a provider name where EN uses
+ * `{provider_name}`, so the Synology banner showed "Immich". It also catches
+ * translated placeholder names (e.g. `{versiyon}`), which render literally.
+ *
+ * Only keys the locale actually translates are checked — missing keys are
+ * key-set drift, handled (as unenforced data) by i18n-parity.spec.ts.
+ */
+const LOCALES: Record = {
+ ar,
+ br,
+ ca,
+ cs,
+ de,
+ es,
+ fr,
+ gr,
+ hu,
+ id,
+ it: itIT,
+ ja,
+ ko,
+ nl,
+ pl,
+ ru,
+ sv,
+ tr,
+ uk,
+ vi,
+ zh,
+ 'zh-TW': zhTW,
+};
+
+const PLACEHOLDER_RE = /\{[a-zA-Z0-9_]+\}/g;
+
+describe('i18n placeholder parity', () => {
+ it('every EN placeholder appears in each locale translation of the same key', () => {
+ const violations: string[] = [];
+
+ for (const [enKey, enValue] of Object.entries(en)) {
+ // A few catalog entries are structured data, not strings
+ // (e.g. packing.suggestions.items) — placeholders only apply to strings.
+ if (typeof enValue !== 'string') continue;
+ const placeholders = enValue.match(PLACEHOLDER_RE);
+ if (!placeholders) continue;
+
+ for (const [locale, catalog] of Object.entries(LOCALES)) {
+ const translated = catalog[enKey];
+ if (typeof translated !== 'string') continue;
+ for (const placeholder of new Set(placeholders)) {
+ if (!translated.includes(placeholder)) {
+ violations.push(`${locale} ${enKey}: missing ${placeholder} in ${JSON.stringify(translated)}`);
+ }
+ }
+ }
+ }
+
+ expect(violations).toEqual([]);
+ });
+});
diff --git a/shared/src/i18n/it/memories.ts b/shared/src/i18n/it/memories.ts
index 8960fbbd7..f8edf87ea 100644
--- a/shared/src/i18n/it/memories.ts
+++ b/shared/src/i18n/it/memories.ts
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Foto',
- 'memories.notConnected': 'Immich non connesso',
+ 'memories.notConnected': '{provider_name} non connesso',
'memories.notConnectedHint':
- 'Connetti la tua istanza Immich nelle Impostazioni per vedere qui le foto del tuo viaggio.',
+ 'Connetti la tua istanza {provider_name} nelle Impostazioni per vedere qui le foto del tuo viaggio.',
'memories.notConnectedMultipleHint':
'Collega uno di questi provider di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.',
'memories.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.',
'memories.noPhotos': 'Nessuna foto trovata',
- 'memories.noPhotosHint': "Nessuna foto trovata in Immich per l'intervallo di date di questo viaggio.",
+ 'memories.noPhotosHint': "Nessuna foto trovata in {provider_name} per l'intervallo di date di questo viaggio.",
'memories.photosFound': 'foto',
'memories.fromOthers': 'da altri',
'memories.sharePhotos': 'Condividi foto',
@@ -30,8 +30,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Testa prima la connessione',
'memories.connected': 'Connesso',
'memories.disconnected': 'Non connesso',
- 'memories.connectionSuccess': 'Connesso a Immich',
- 'memories.connectionError': 'Impossibile connettersi a Immich',
+ 'memories.connectionSuccess': 'Connesso a {provider_name}',
+ 'memories.connectionError': 'Impossibile connettersi a {provider_name}',
'memories.saved': 'Impostazioni {provider_name} salvate',
'memories.providerDisconnectedBanner':
'La connessione a {provider_name} è persa. Riconnetti nelle Impostazioni per visualizzare le foto.',
@@ -41,13 +41,13 @@ const memories: TranslationStrings = {
'memories.fillRequiredFields': 'Per favore compila tutti i campi obbligatori',
'memories.addPhotos': 'Aggiungi foto',
'memories.linkAlbum': 'Collega album',
- 'memories.selectAlbum': 'Seleziona album Immich',
+ 'memories.selectAlbum': 'Seleziona album {provider_name}',
'memories.selectAlbumMultiple': 'Seleziona album',
'memories.noAlbums': 'Nessun album trovato',
'memories.syncAlbum': 'Sincronizza album',
'memories.unlinkAlbum': 'Scollega',
'memories.photos': 'foto',
- 'memories.selectPhotos': 'Seleziona foto da Immich',
+ 'memories.selectPhotos': 'Seleziona foto da {provider_name}',
'memories.selectPhotosMultiple': 'Seleziona foto',
'memories.selectHint': 'Tocca le foto per selezionarle.',
'memories.selected': 'selezionate',
diff --git a/shared/src/i18n/nl/memories.ts b/shared/src/i18n/nl/memories.ts
index db5bb19d3..4b3e5dc57 100644
--- a/shared/src/i18n/nl/memories.ts
+++ b/shared/src/i18n/nl/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': "Foto's",
- 'memories.notConnected': 'Immich niet verbonden',
- 'memories.notConnectedHint': "Verbind je Immich-instantie in Instellingen om je reisfoto's hier te zien.",
+ 'memories.notConnected': '{provider_name} niet verbonden',
+ 'memories.notConnectedHint': "Verbind je {provider_name}-instantie in Instellingen om je reisfoto's hier te zien.",
'memories.notConnectedMultipleHint':
"Verbind een van deze fotoproviders: {provider_names} in Instellingen om foto's aan dit reisplan toe te voegen.",
'memories.noDates': "Voeg data toe aan je reis om foto's te laden.",
'memories.noPhotos': "Geen foto's gevonden",
- 'memories.noPhotosHint': "Geen foto's gevonden in Immich voor de datumreeks van deze reis.",
+ 'memories.noPhotosHint': "Geen foto's gevonden in {provider_name} voor de datumreeks van deze reis.",
'memories.photosFound': "foto's",
'memories.fromOthers': 'van anderen',
'memories.sharePhotos': "Foto's delen",
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Test eerst de verbinding',
'memories.connected': 'Verbonden',
'memories.disconnected': 'Niet verbonden',
- 'memories.connectionSuccess': 'Verbonden met Immich',
- 'memories.connectionError': 'Kon niet verbinden met Immich',
+ 'memories.connectionSuccess': 'Verbonden met {provider_name}',
+ 'memories.connectionError': 'Kon niet verbinden met {provider_name}',
'memories.saved': '{provider_name}-instellingen opgeslagen',
'memories.providerDisconnectedBanner':
"Je {provider_name}-verbinding is verbroken. Maak opnieuw verbinding in Instellingen om foto's te bekijken.",
@@ -43,13 +43,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Alle locaties',
'memories.addPhotos': "Foto's toevoegen",
'memories.linkAlbum': 'Album koppelen',
- 'memories.selectAlbum': 'Immich-album selecteren',
+ 'memories.selectAlbum': '{provider_name}-album selecteren',
'memories.selectAlbumMultiple': 'Album selecteren',
'memories.noAlbums': 'Geen albums gevonden',
'memories.syncAlbum': 'Album synchroniseren',
'memories.unlinkAlbum': 'Ontkoppelen',
'memories.photos': 'fotos',
- 'memories.selectPhotos': "Selecteer foto's uit Immich",
+ 'memories.selectPhotos': "Selecteer foto's uit {provider_name}",
'memories.selectPhotosMultiple': "Foto's selecteren",
'memories.selectHint': "Tik op foto's om ze te selecteren.",
'memories.selected': 'geselecteerd',
diff --git a/shared/src/i18n/pl/memories.ts b/shared/src/i18n/pl/memories.ts
index fc238f598..013425ddf 100644
--- a/shared/src/i18n/pl/memories.ts
+++ b/shared/src/i18n/pl/memories.ts
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Zdjęcia',
- 'memories.notConnected': 'Immich nie jest połączony',
+ 'memories.notConnected': '{provider_name} nie jest połączony',
'memories.notConnectedHint':
- 'Połącz swoją instancję Immich w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.',
+ 'Połącz swoją instancję {provider_name} w ustawieniach, aby przeglądać tutaj swoje zdjęcia z podróży.',
'memories.notConnectedMultipleHint':
'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby móc dodawać zdjęcia do tej podróży.',
'memories.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.',
'memories.noPhotos': 'Nie znaleziono zdjęć',
- 'memories.noPhotosHint': 'Nie znaleziono zdjęć w Immich dla tego zakresu dat podróży.',
+ 'memories.noPhotosHint': 'Nie znaleziono zdjęć w {provider_name} dla tego zakresu dat podróży.',
'memories.photosFound': 'zdjęć',
'memories.fromOthers': 'od innych',
'memories.sharePhotos': 'Udostępnij zdjęcia',
@@ -29,14 +29,14 @@ const memories: TranslationStrings = {
'memories.testShort': 'Test',
'memories.connected': 'Połączono',
'memories.disconnected': 'Nie połączono',
- 'memories.connectionSuccess': 'Połączono z Immich',
- 'memories.connectionError': 'Nie udało się połączyć z Immich',
+ 'memories.connectionSuccess': 'Połączono z {provider_name}',
+ 'memories.connectionError': 'Nie udało się połączyć z {provider_name}',
'memories.saved': 'Ustawienia {provider_name} zostały zapisane',
'memories.providerDisconnectedBanner':
'Połączenie z {provider_name} zostało utracone. Połącz ponownie w Ustawieniach, aby wyświetlać zdjęcia.',
'memories.saveError': 'Nie można zapisać ustawień {provider_name}',
'memories.addPhotos': 'Dodaj zdjęcia',
- 'memories.selectPhotos': 'Wybierz zdjęcia z Immich',
+ 'memories.selectPhotos': 'Wybierz zdjęcia z {provider_name}',
'memories.selectPhotosMultiple': 'Wybierz zdjęcia',
'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.',
'memories.selected': 'wybranych',
@@ -55,7 +55,7 @@ const memories: TranslationStrings = {
'memories.confirmShareButton': 'Udostępnij zdjęcia',
'memories.testFirst': 'Najpierw przetestuj połączenie',
'memories.linkAlbum': 'Połącz album',
- 'memories.selectAlbum': 'Wybierz album Immich',
+ 'memories.selectAlbum': 'Wybierz album {provider_name}',
'memories.selectAlbumMultiple': 'Wybierz album',
'memories.noAlbums': 'Nie znaleziono albumów',
'memories.syncAlbum': 'Synchronizuj album',
diff --git a/shared/src/i18n/pl/places.ts b/shared/src/i18n/pl/places.ts
index 34a976ea2..35f0336ba 100644
--- a/shared/src/i18n/pl/places.ts
+++ b/shared/src/i18n/pl/places.ts
@@ -79,7 +79,7 @@ const places: TranslationStrings = {
'places.importNaverList': 'Lista Naver',
'places.importList': 'Import listy',
'places.googleListHint': 'Wklej link do listy Google Maps.',
- 'places.googleListImported': 'Zaimportowano {count} miejsc',
+ 'places.googleListImported': 'Zaimportowano {count} miejsc z „{list}”',
'places.googleListError': 'Nie udało się zaimportować listy',
'places.naverListHint': 'Wklej link do udostępnionej listy Naver Maps, aby zaimportować wszystkie miejsca.',
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
diff --git a/shared/src/i18n/ru/memories.ts b/shared/src/i18n/ru/memories.ts
index fb55c1641..9f602116a 100644
--- a/shared/src/i18n/ru/memories.ts
+++ b/shared/src/i18n/ru/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Фото',
- 'memories.notConnected': 'Immich не подключён',
- 'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
+ 'memories.notConnected': '{provider_name} не подключён',
+ 'memories.notConnectedHint': 'Подключите {provider_name} в настройках, чтобы видеть фотографии из поездок.',
'memories.notConnectedMultipleHint':
'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.',
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
'memories.noPhotos': 'Фотографии не найдены',
- 'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
+ 'memories.noPhotosHint': 'В {provider_name} нет фотографий за период этой поездки.',
'memories.photosFound': 'фото',
'memories.fromOthers': 'от других',
'memories.sharePhotos': 'Поделиться фото',
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Сначала проверьте подключение',
'memories.connected': 'Подключено',
'memories.disconnected': 'Не подключено',
- 'memories.connectionSuccess': 'Подключение к Immich установлено',
- 'memories.connectionError': 'Не удалось подключиться к Immich',
+ 'memories.connectionSuccess': 'Подключение к {provider_name} установлено',
+ 'memories.connectionError': 'Не удалось подключиться к {provider_name}',
'memories.saved': 'Настройки {provider_name} сохранены',
'memories.providerDisconnectedBanner':
'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.',
@@ -40,13 +40,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Все места',
'memories.addPhotos': 'Добавить фото',
'memories.linkAlbum': 'Привязать альбом',
- 'memories.selectAlbum': 'Выбрать альбом Immich',
+ 'memories.selectAlbum': 'Выбрать альбом {provider_name}',
'memories.selectAlbumMultiple': 'Выбрать альбом',
'memories.noAlbums': 'Альбомы не найдены',
'memories.syncAlbum': 'Синхронизировать',
'memories.unlinkAlbum': 'Отвязать',
'memories.photos': 'фото',
- 'memories.selectPhotos': 'Выбрать фото из Immich',
+ 'memories.selectPhotos': 'Выбрать фото из {provider_name}',
'memories.selectPhotosMultiple': 'Выбрать фотографии',
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
'memories.selected': 'выбрано',
diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts
index c19468af0..96e540e4e 100644
--- a/shared/src/i18n/tr/admin.ts
+++ b/shared/src/i18n/tr/admin.ts
@@ -602,7 +602,7 @@ const admin: TranslationStrings = {
'admin.github.by': 'ile',
'admin.github.support': "TREK'i geliştirmeye devam etmeme yardımcı oluyor",
'admin.update.available': 'Güncelleme mevcut',
- 'admin.update.text': 'TREK {versiyon} mevcut. {current} çalıştırıyorsunuz.',
+ 'admin.update.text': 'TREK {version} mevcut. {current} çalıştırıyorsunuz.',
'admin.update.button': "GitHub'da görüntüle",
'admin.update.install': 'Güncellemeyi Yükle',
'admin.update.confirmTitle': 'Güncelleme Yüklensin mi?',
diff --git a/shared/src/i18n/tr/system_notice.ts b/shared/src/i18n/tr/system_notice.ts
index 378318e19..12c7cd35f 100644
--- a/shared/src/i18n/tr/system_notice.ts
+++ b/shared/src/i18n/tr/system_notice.ts
@@ -51,7 +51,7 @@ const system_notice: TranslationStrings = {
'system_notice.thank_you_support.cta_kofi': "Ko-fi'de Destek Ol",
'system_notice.pager.prev': 'Önceki bildirim',
'system_notice.pager.next': 'Sonraki bildirim',
- 'system_notice.pager.counter': '{güncel} / {toplam}',
+ 'system_notice.pager.counter': '{current} / {total}',
'system_notice.pager.goto': '{n}. bildirime git',
'system_notice.pager.position': '{total} Bildirimden {current}.',
};
diff --git a/shared/src/i18n/tr/vacay.ts b/shared/src/i18n/tr/vacay.ts
index 8aad56c49..f1b169589 100644
--- a/shared/src/i18n/tr/vacay.ts
+++ b/shared/src/i18n/tr/vacay.ts
@@ -31,7 +31,7 @@ const vacay: TranslationStrings = {
'vacay.entitlementDays': 'Günler',
'vacay.used': 'Kullanılmış',
'vacay.remaining': 'Sol',
- 'vacay.carriedOver': '{yıl} tarihinden itibaren',
+ 'vacay.carriedOver': '{year} tarihinden itibaren',
'vacay.blockWeekends': 'Hafta Sonlarını Engelle',
'vacay.blockWeekendsHint': 'Hafta sonu günlerinde tatil girişlerini önleyin',
'vacay.weekendDays': 'Hafta sonu günleri',
diff --git a/shared/src/i18n/uk/memories.ts b/shared/src/i18n/uk/memories.ts
index bcd30796d..43acb974f 100644
--- a/shared/src/i18n/uk/memories.ts
+++ b/shared/src/i18n/uk/memories.ts
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': 'Фото',
- 'memories.notConnected': 'Immich не підключено',
- 'memories.notConnectedHint': 'Підключіть Immich у налаштуваннях, щоб бачити фотографії з поїздок.',
+ 'memories.notConnected': '{provider_name} не підключено',
+ 'memories.notConnectedHint': 'Підключіть {provider_name} у налаштуваннях, щоб бачити фотографії з поїздок.',
'memories.notConnectedMultipleHint':
'Підключіть одного з цих фотопровайдерів: {provider_names} у Налаштуваннях, щоб додавати фотографії до цієї подорожі.',
'memories.noDates': 'Додайте дати поїздки, щоб завантажити фотографії.',
'memories.noPhotos': 'Фотографій не знайдено',
- 'memories.noPhotosHint': 'В Immich немає фотографій за період цієї поїздки.',
+ 'memories.noPhotosHint': 'В {provider_name} немає фотографій за період цієї поїздки.',
'memories.photosFound': 'фото',
'memories.fromOthers': 'від інших',
'memories.sharePhotos': 'Поділитися фото',
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
'memories.testFirst': 'Спершу перевірте підключення',
'memories.connected': 'Підключено',
'memories.disconnected': 'Не підключено',
- 'memories.connectionSuccess': 'Підключення до Immich встановлено',
- 'memories.connectionError': 'Не вдалося підключитися до Immich',
+ 'memories.connectionSuccess': 'Підключення до {provider_name} встановлено',
+ 'memories.connectionError': 'Не вдалося підключитися до {provider_name}',
'memories.saved': 'Налаштування {provider_name} збережено',
'memories.providerDisconnectedBanner':
'З’єднання з {provider_name} втрачено. Перепідключіться в Налаштуваннях для перегляду фото.',
@@ -40,13 +40,13 @@ const memories: TranslationStrings = {
'memories.allLocations': 'Усі місця',
'memories.addPhotos': 'Додати фото',
'memories.linkAlbum': 'Прив’язати альбом',
- 'memories.selectAlbum': 'Вибрати альбом Immich',
+ 'memories.selectAlbum': 'Вибрати альбом {provider_name}',
'memories.selectAlbumMultiple': 'Вибрати альбом',
'memories.noAlbums': 'Альбомів не знайдено',
'memories.syncAlbum': 'Синхронізувати',
'memories.unlinkAlbum': 'Відв’язати',
'memories.photos': 'фото',
- 'memories.selectPhotos': 'Вибрати фото з Immich',
+ 'memories.selectPhotos': 'Вибрати фото з {provider_name}',
'memories.selectPhotosMultiple': 'Вибрати фотографії',
'memories.selectHint': 'Натисніть на фото, щоб вибрати їх.',
'memories.selected': 'вибрано',
diff --git a/shared/src/i18n/zh/memories.ts b/shared/src/i18n/zh/memories.ts
index 0fb89c290..6b53f005c 100644
--- a/shared/src/i18n/zh/memories.ts
+++ b/shared/src/i18n/zh/memories.ts
@@ -2,12 +2,12 @@ import type { TranslationStrings } from '../types';
const memories: TranslationStrings = {
'memories.title': '照片',
- 'memories.notConnected': 'Immich 未连接',
- 'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
+ 'memories.notConnected': '{provider_name} 未连接',
+ 'memories.notConnectedHint': '在设置中连接您的 {provider_name} 实例以在此查看旅行照片。',
'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。',
'memories.noDates': '为旅行添加日期以加载照片。',
'memories.noPhotos': '未找到照片',
- 'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
+ 'memories.noPhotosHint': '{provider_name} 中未找到此旅行日期范围内的照片。',
'memories.photosFound': '张照片',
'memories.fromOthers': '来自他人',
'memories.sharePhotos': '分享照片',
@@ -28,8 +28,8 @@ const memories: TranslationStrings = {
'memories.testFirst': '请先测试连接',
'memories.connected': '已连接',
'memories.disconnected': '未连接',
- 'memories.connectionSuccess': '已连接到 Immich',
- 'memories.connectionError': '无法连接到 Immich',
+ 'memories.connectionSuccess': '已连接到 {provider_name}',
+ 'memories.connectionError': '无法连接到 {provider_name}',
'memories.saved': '{provider_name} 设置已保存',
'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。',
'memories.saveError': '无法保存 {provider_name} 设置',
@@ -38,13 +38,13 @@ const memories: TranslationStrings = {
'memories.allLocations': '所有地点',
'memories.addPhotos': '添加照片',
'memories.linkAlbum': '关联相册',
- 'memories.selectAlbum': '选择 Immich 相册',
+ 'memories.selectAlbum': '选择 {provider_name} 相册',
'memories.selectAlbumMultiple': '选择相册',
'memories.noAlbums': '未找到相册',
'memories.syncAlbum': '同步相册',
'memories.unlinkAlbum': '取消关联',
'memories.photos': '张照片',
- 'memories.selectPhotos': '从 Immich 选择照片',
+ 'memories.selectPhotos': '从 {provider_name} 选择照片',
'memories.selectPhotosMultiple': '选择照片',
'memories.selectHint': '点击照片以选择。',
'memories.selected': '已选择',
diff --git a/unraid-template.xml b/unraid-template.xml
index 19e481e0c..0e6069960 100644
--- a/unraid-template.xml
+++ b/unraid-template.xml
@@ -1,8 +1,8 @@
TREK
- mauriceboe/TREK
- https://hub.docker.com/r/mauriceboe/TREK
+ mauriceboe/trek
+ https://hub.docker.com/r/mauriceboe/trek
latest
Latest stable release
diff --git a/wiki/Install-Docker-Compose.md b/wiki/Install-Docker-Compose.md
index cabd6fb71..da08b8690 100644
--- a/wiki/Install-Docker-Compose.md
+++ b/wiki/Install-Docker-Compose.md
@@ -66,15 +66,15 @@ Three tag strategies are available:
| Tag | Example | Behavior |
|---|--------------------------|---|
-| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
-| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
-| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
+| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
+| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
+| Full version | `mauriceboe/trek:3.4.0` | Exact release; never changes |
The compose file above uses `latest`. To pin, change the `image:` line:
```yaml
-image: mauriceboe/TREK:3 # track major version 3
-image: mauriceboe/TREK:3.4.0 # pin to exact release
+image: mauriceboe/trek:3 # track major version 3
+image: mauriceboe/trek:3.4.0 # pin to exact release
```
## Start TREK
diff --git a/wiki/Install-Docker.md b/wiki/Install-Docker.md
index a53ce2f54..4b172184a 100644
--- a/wiki/Install-Docker.md
+++ b/wiki/Install-Docker.md
@@ -12,7 +12,7 @@ docker run -d \
-v ./uploads:/app/uploads \
-e ENCRYPTION_KEY= \
--restart unless-stopped \
- mauriceboe/TREK:latest
+ mauriceboe/trek:latest
```
`ENCRYPTION_KEY` is strongly recommended but not strictly required. If omitted, a key is auto-generated on first start and persisted to `data/.encryption_key`. Setting it explicitly means you can recreate the container from scratch (e.g. on a new host) without losing access to stored encrypted data (API keys, SMTP credentials, OIDC secrets, MFA secrets).
@@ -36,13 +36,13 @@ See [Environment-Variables](Environment-Variables) for the full list.
## Image Tags
-| Tag | Example | Behavior |
-|---|---|---|
-| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
-| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
-| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
+| Tag | Example | Behavior |
+|---|--------------------------|---|
+| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
+| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
+| Full version | `mauriceboe/trek:3.4.0` | Exact release; never changes |
-Replace `mauriceboe/TREK:latest` in the run command with your chosen tag to pin to a major version or exact release.
+Replace `mauriceboe/trek:latest` in the run command with your chosen tag to pin to a major version or exact release.
## Volume Reference
@@ -65,7 +65,7 @@ docker run -d \
-v trek_uploads:/app/uploads \
-e ENCRYPTION_KEY= \
--restart unless-stopped \
- mauriceboe/TREK:latest
+ mauriceboe/trek:latest
```
Docker creates `trek_data` and `trek_uploads` automatically on first run. Named volumes are easier to manage with `docker volume` commands and work better in some NAS or container-management environments.
diff --git a/wiki/Install-Helm.md b/wiki/Install-Helm.md
index cb85ac78c..87ee8cb3c 100644
--- a/wiki/Install-Helm.md
+++ b/wiki/Install-Helm.md
@@ -5,10 +5,22 @@ Deploy TREK on Kubernetes using the official Helm chart.
## Add the Chart Repository
```bash
-helm repo add trek https://mauriceboe.github.io/TREK
+helm repo add trek https://chart.liketrek.com
helm repo update
```
+> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both serve the same chart repository. Using the custom domain keeps your setup working even if the GitHub repository moves again.
+
+> **⚠️ Repository moved:** The chart is no longer served at `https://mauriceboe.github.io/TREK` (the project moved from a personal repo to the `liketrek` organization). If you added the repo from the old URL, switch to the new one:
+>
+> ```bash
+> helm repo remove trek
+> helm repo add trek https://chart.liketrek.com
+> helm repo update
+> ```
+>
+> Existing releases keep working — only the repo URL changes; future `helm repo update` / `helm upgrade` runs require the new URL. (`https://liketrek.github.io/TREK` also works — it redirects to `chart.liketrek.com`.)
+
## Basic Install
```bash
@@ -72,7 +84,7 @@ helm install trek trek/trek \
```yaml
image:
- repository: liketrek/TREK
+ repository: mauriceboe/trek
# tag: latest # defaults to the chart's appVersion
pullPolicy: IfNotPresent
diff --git a/wiki/Install-Portainer.md b/wiki/Install-Portainer.md
index 23f016647..207709a35 100644
--- a/wiki/Install-Portainer.md
+++ b/wiki/Install-Portainer.md
@@ -40,9 +40,9 @@ Three tag strategies are available:
| Tag | Example | Behavior |
|---|---|---|
-| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
-| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
-| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
+| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
+| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
+| Full version | `mauriceboe/trek:3.4.0` | Exact release; never changes |
Use `latest` or a major-version tag (e.g. `3`) if you want automatic updates on redeploy. Use a full version tag (e.g. `3.4.0`) if you want explicit control over which release runs.
diff --git a/wiki/Quick-Start.md b/wiki/Quick-Start.md
index 857162d26..17e7b970d 100644
--- a/wiki/Quick-Start.md
+++ b/wiki/Quick-Start.md
@@ -21,7 +21,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d \
-v ./data:/app/data \
-v ./uploads:/app/uploads \
--restart unless-stopped \
- mauriceboe/TREK:latest
+ mauriceboe/trek:latest
```
**Flag breakdown:**
diff --git a/wiki/Updating.md b/wiki/Updating.md
index 5fdc9df21..71644ecc1 100644
--- a/wiki/Updating.md
+++ b/wiki/Updating.md
@@ -10,9 +10,9 @@ Back up your data first. Go to Admin Panel → Backups and create a manual backu
| Tag | Example | Behavior |
|---|---|---|
-| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
-| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
-| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
+| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
+| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
+| Full version | `mauriceboe/trek:3.4.0` | Exact release; never changes |
Use `latest` or a major-version tag if you want updates on each redeploy. Use a full version tag for explicit control — update by changing the tag, not by re-pulling.
@@ -39,14 +39,14 @@ docker compose up -d
If you started TREK with `docker run`, pull the new image and replace the container:
```bash
-docker pull mauriceboe/TREK
+docker pull mauriceboe/trek
docker rm -f trek
docker run -d --name trek -p 3000:3000 \
-v ./data:/app/data \
-v ./uploads:/app/uploads \
-e ENCRYPTION_KEY= \
--restart unless-stopped \
- mauriceboe/TREK
+ mauriceboe/trek
```
> **Tip:** Not sure which volume paths you used? Check before removing:
@@ -54,6 +54,28 @@ docker run -d --name trek -p 3000:3000 \
> docker inspect trek --format '{{json .Mounts}}'
> ```
+## Helm (Kubernetes)
+
+> **⚠️ Chart repository moved:** The Helm chart is no longer served at `https://mauriceboe.github.io/TREK` (the project moved from a personal repo to the `liketrek` organization). The canonical chart URL is now `https://chart.liketrek.com` — a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK`, so it stays stable even if the repository moves again. If your `trek` repo still points to an old URL, switch it before updating:
+>
+> ```bash
+> helm repo remove trek
+> helm repo add trek https://chart.liketrek.com
+> ```
+>
+> You can check which URL you have configured with `helm repo list`. Existing releases are unaffected — only the repo URL changes.
+
+To update to the newest chart release:
+
+```bash
+helm repo update
+helm upgrade trek trek/trek
+```
+
+Your existing values and PVCs (data, uploads) are preserved. To pin an exact chart version instead, pass `--version `.
+
+See [Install-Helm](Install-Helm) for the full installation walkthrough and values reference.
+
## Database Migrations
TREK runs any pending database migrations automatically at startup. No manual migration steps are required after pulling a new image.