diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 341a06438..23615353c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -208,3 +208,4 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} charts_dir: charts + charts_url: https://chart.liketrek.com diff --git a/README.md b/README.md index 2f4fdcf8f..65477543f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa Demo   -Docker +Docker   Discord   @@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa BMAC
License -Latest Release -Docker Pulls -Stars +Latest Release +Docker Pulls +Stars @@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa ```bash ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \ -e ENCRYPTION_KEY=$ENCRYPTION_KEY \ - -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/TREK + -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek ``` Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`). @@ -217,7 +217,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A ```yaml services: app: - image: mauriceboe/TREK:latest + image: mauriceboe/trek:latest container_name: trek read_only: true security_opt: @@ -275,7 +275,7 @@ docker compose up -d

Helm (Kubernetes)

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