* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz

* fix(transit): lead arrive-by results with the latest arrival (#1479)

MOTIS returns arrive-by itineraries ascending with the deadline-adjacent
connection last, so the visible top of the list arrived ~2h before the
requested time — misread as a timezone bug. Sort arrive-by results by
endTime descending so the connection closest to the requested arrival
leads, mirroring depart-by.

* fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602)

The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'.
The modal mounts closed, so the first open render executed one more hook
than the previous render and React unmounted the whole tree (error #310),
blanking the page. Move the memo above the early return and cover the
closed-then-open transition with a regression test.

* fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607)

On an accommodation's check-in day the hotel -> first-stop leg was drawn
by default for any PLACE first stop, suppressed only when that place was
explicitly timed before check-in. An un-timed first place (e.g. "Home"
on day 1 of a driving holiday) therefore always produced a phantom
hotel -> Home leg, regardless of the check-in time.

Flip the default to mirror shouldDrawEveningLeg: the morning leg is now
drawn only when the first place is provably timed at/after check-in
(you dropped your bags first). The drawn map route, the sidebar hotel
connectors, and the Google Maps export all share this helper and
inherit the fix. The optimizer anchors (#1321) are unchanged.

* fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608)

* chore: correct shields.io url

* chore: correct shields.io url

* chore: update helm repo link

* chore: document new helm chart url

* chore: document new helm chart url

* fix(atlas): make Kosovo selectable on the Atlas map (#1609)

Kosovo's user-assigned ISO code (XK/XKX) was missing from both code
tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the
shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3
map. With neither resolvable, onEachFeature attached no hover/click
handlers to Kosovo's polygon and the country search dropped it, making
Kosovo impossible to select.

- add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map
- stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle
- guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003)

* chore: normalize docker image references to mauriceboe/trek (lowercase)

* fix(memories): honor Synology skip-SSL on photo streaming (#1611)

The synology_skip_ssl setting was forwarded on the JSON API path
(login/browse/test-connection) but not on the image-byte fetches, so a
NAS with a self-signed certificate passed Test Connection while
/api/photos/:id/thumbnail and /original failed with 500
"Failed to fetch asset" — and the TLS error was silently swallowed.

- pipeAsset: accept fetchOptions and forward to safeFetch; log the
  underlying error on the 500 path (without the URL, which carries _sid)
- fetchSynologyThumbnailBytes / streamSynologyAsset: pass
  rejectUnauthorized derived from synology_skip_ssl; log on failure
- i18n: replace hardcoded "Immich" with {provider_name} in 7 memories
  keys across 14 locales (source of the reported "Failed to connect to
  Immich" banner on the Synology test), fix translated placeholder
  names in tr/ca/pl, and add a placeholder-parity spec to prevent
  regressions
- client: gallery provider badge matched 'synology' instead of the real
  id 'synologyphotos', showing the raw id; label is now
  "Synology Photos"

* chore: make chart.liketrek.com the canonical helm chart url

* feat(plugins): prefer the registry's resolved store screenshot (#1613)

Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png.

---------

Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
This commit is contained in:
jubnl
2026-07-19 19:00:40 +02:00
committed by GitHub
parent 3ca1ef34bb
commit 91095ef96a
58 changed files with 673 additions and 223 deletions
+1
View File
@@ -208,3 +208,4 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
charts_dir: charts
charts_url: https://chart.liketrek.com
+9 -9
View File
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
&nbsp;
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
&nbsp;
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
&nbsp;
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
<br />
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/TREK?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
</div>
@@ -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
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
```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.
+1 -1
View File
@@ -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.
+3 -1
View File
@@ -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:
+1 -1
View File
@@ -1,6 +1,6 @@
image:
repository: liketrek/TREK
repository: mauriceboe/trek
# tag: latest
pullPolicy: IfNotPresent
@@ -194,7 +194,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
<div className="absolute top-1.5 left-1.5">
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
<RefreshCw size={7} />
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synologyphotos' ? 'Synology Photos' : photo.provider}
</span>
</div>
)}
@@ -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(<AirTrailImportModal {...defaultProps} isOpen={false} />);
rerender(<AirTrailImportModal {...defaultProps} isOpen={true} />);
expect(await screen.findByText('Import from AirTrail')).toBeInTheDocument();
});
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
render(<AirTrailImportModal {...defaultProps} />);
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
@@ -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<string>()
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<string>()
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!)
@@ -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(<TransitSearchPanel {...makeProps()} />)
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(<TransitSearchPanel {...makeProps()} />)
@@ -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'))
+10 -7
View File
@@ -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();
});
});
+2 -2
View File
@@ -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`}
</div>
)}
+20 -1
View File
@@ -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');
});
});
+6 -5
View File
@@ -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<string, string> = {"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<string, string> = {"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"}
+5 -5
View File
@@ -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)', () => {
+7 -6
View File
@@ -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
@@ -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.
+1 -1
View File
@@ -1,6 +1,6 @@
services:
app:
image: mauriceboe/TREK:latest
image: mauriceboe/trek:latest
container_name: trek
read_only: true
security_opt:
Binary file not shown.
+6 -5
View File
@@ -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)
@@ -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),
@@ -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<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string, fetchOptions?: SafeFetchOptions): Promise<void> {
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' });
}
}
@@ -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 })
}
+12 -3
View File
@@ -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) {
@@ -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 });
},
@@ -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);
}
});
});
@@ -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({
@@ -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 {
@@ -216,3 +216,61 @@ describe('pipeAsset', () => {
expect(res.end).toHaveBeenCalled();
});
});
// ── pipeAsset fetch options (#1611) ───────────────────────────────────────────
describe('pipeAsset fetch options (#1611)', () => {
function mockResponse(overrides: Record<string, any> = {}) {
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();
});
});
@@ -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();
@@ -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');
});
});
+7 -7
View File
@@ -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': 'محدد',
+7 -7
View File
@@ -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',
+1 -1
View File
@@ -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",
+7 -7
View File
@@ -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)',
+7 -7
View File
@@ -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',
+7 -7
View File
@@ -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',
+7 -7
View File
@@ -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)',
+7 -7
View File
@@ -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)',
+7 -7
View File
@@ -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',
+91
View File
@@ -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<string, TranslationStrings> = {
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([]);
});
});
+7 -7
View File
@@ -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',
+7 -7
View File
@@ -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',
+7 -7
View File
@@ -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',
+1 -1
View File
@@ -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}"',
+7 -7
View File
@@ -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': 'выбрано',
+1 -1
View File
@@ -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?',
+1 -1
View File
@@ -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}.',
};
+1 -1
View File
@@ -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',
+7 -7
View File
@@ -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': 'вибрано',
+7 -7
View File
@@ -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': '已选择',
+2 -2
View File
@@ -1,8 +1,8 @@
<?xml version="1.0"?>
<Container version="2">
<Name>TREK</Name>
<Repository>mauriceboe/TREK</Repository>
<Registry>https://hub.docker.com/r/mauriceboe/TREK</Registry>
<Repository>mauriceboe/trek</Repository>
<Registry>https://hub.docker.com/r/mauriceboe/trek</Registry>
<Branch>
<Tag>latest</Tag>
<TagDescription>Latest stable release</TagDescription>
+5 -5
View File
@@ -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
+7 -7
View File
@@ -12,7 +12,7 @@ docker run -d \
-v ./uploads:/app/uploads \
-e ENCRYPTION_KEY=<your-32-byte-hex-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).
@@ -37,12 +37,12 @@ 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 |
|---|--------------------------|---|
| `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=<your-32-byte-hex-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.
+14 -2
View File
@@ -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
+3 -3
View File
@@ -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.
+1 -1
View File
@@ -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:**
+27 -5
View File
@@ -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=<your-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 <x.y.z>`.
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.