mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
v3.4.1 (#1606)
* fix(transit): lead arrive-by results with the latest arrival (#1479) MOTIS returns arrive-by itineraries ascending with the deadline-adjacent connection last, so the visible top of the list arrived ~2h before the requested time — misread as a timezone bug. Sort arrive-by results by endTime descending so the connection closest to the requested arrival leads, mirroring depart-by. Claude-Session: https://claude.ai/code/session_01BCrZUoPzHZF6H4C6avMXoz * fix(transit): lead arrive-by results with the latest arrival (#1479) MOTIS returns arrive-by itineraries ascending with the deadline-adjacent connection last, so the visible top of the list arrived ~2h before the requested time — misread as a timezone bug. Sort arrive-by results by endTime descending so the connection closest to the requested arrival leads, mirroring depart-by. * fix(reservations): run AirTrail modal hooks before the isOpen early return (#1602) The sectionItems useMemo added by #1535 sat below 'if (!isOpen) return null'. The modal mounts closed, so the first open render executed one more hook than the previous render and React unmounted the whole tree (error #310), blanking the page. Move the memo above the early return and cover the closed-then-open transition with a regression test. * fix(planner): only draw the check-in-day hotel morning leg when provably at/after check-in (#1597) (#1607) On an accommodation's check-in day the hotel -> first-stop leg was drawn by default for any PLACE first stop, suppressed only when that place was explicitly timed before check-in. An un-timed first place (e.g. "Home" on day 1 of a driving holiday) therefore always produced a phantom hotel -> Home leg, regardless of the check-in time. Flip the default to mirror shouldDrawEveningLeg: the morning leg is now drawn only when the first place is provably timed at/after check-in (you dropped your bags first). The drawn map route, the sidebar hotel connectors, and the Google Maps export all share this helper and inherit the fix. The optimizer anchors (#1321) are unchanged. * fix(notifications): never fall back to the admin ntfy topic for per-user sends (#1608) * chore: correct shields.io url * chore: correct shields.io url * chore: update helm repo link * chore: document new helm chart url * chore: document new helm chart url * fix(atlas): make Kosovo selectable on the Atlas map (#1609) Kosovo's user-assigned ISO code (XK/XKX) was missing from both code tables in the atlas pipeline: the geo builder's A3_TO_A2 map (so the shipped admin0 bundle carried ISO_A2: null) and the client's A2_TO_A3 map. With neither resolvable, onEachFeature attached no hover/click handlers to Kosovo's polygon and the country search dropped it, making Kosovo impossible to select. - add XK<->XKX to the client A2_TO_A3 table and the builder A3_TO_A2 map - stamp ISO_A2: "XK" on the Kosovo feature in the shipped admin0 bundle - guard both with tests (atlasModel resolution + ATLAS-BUNDLE-003) * chore: normalize docker image references to mauriceboe/trek (lowercase) * fix(memories): honor Synology skip-SSL on photo streaming (#1611) The synology_skip_ssl setting was forwarded on the JSON API path (login/browse/test-connection) but not on the image-byte fetches, so a NAS with a self-signed certificate passed Test Connection while /api/photos/:id/thumbnail and /original failed with 500 "Failed to fetch asset" — and the TLS error was silently swallowed. - pipeAsset: accept fetchOptions and forward to safeFetch; log the underlying error on the 500 path (without the URL, which carries _sid) - fetchSynologyThumbnailBytes / streamSynologyAsset: pass rejectUnauthorized derived from synology_skip_ssl; log on failure - i18n: replace hardcoded "Immich" with {provider_name} in 7 memories keys across 14 locales (source of the reported "Failed to connect to Immich" banner on the Synology test), fix translated placeholder names in tr/ca/pl, and add a placeholder-parity spec to prevent regressions - client: gallery provider badge matched 'synology' instead of the real id 'synologyphotos', showing the raw id; label is now "Synology Photos" * chore: make chart.liketrek.com the canonical helm chart url * feat(plugins): prefer the registry's resolved store screenshot (#1613) Browse/detail use the screenshotUrl the aggregate step resolves (cover at the latest commit, or the first resolving README image), falling back to the docs/screenshot.png guess when the field is absent. Fixes blank store cards for plugins without a committed docs/screenshot.png. --------- Co-authored-by: Maurice <61554723+mauriceboe@users.noreply.github.com>
This commit is contained in:
@@ -208,3 +208,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
charts_dir: charts
|
charts_dir: charts
|
||||||
|
charts_url: https://chart.liketrek.com
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
|
|
||||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||||
|
|
||||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||||
|
|
||||||
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||||
<br />
|
<br />
|
||||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||||
<a href="https://hub.docker.com/r/mauriceboe/TREK"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/TREK?style=flat-square&color=6B7280" /></a>
|
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
```bash
|
```bash
|
||||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
-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`).
|
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
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/TREK:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
container_name: trek
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
@@ -275,7 +275,7 @@ docker compose up -d
|
|||||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm repo add trek https://mauriceboe.github.io/TREK
|
helm repo add trek https://chart.liketrek.com
|
||||||
helm repo update
|
helm repo update
|
||||||
helm install trek trek/trek
|
helm install trek trek/trek
|
||||||
```
|
```
|
||||||
@@ -305,9 +305,9 @@ docker compose pull && docker compose up -d
|
|||||||
**Docker run** — reuse the original volume paths:
|
**Docker run** — reuse the original volume paths:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/TREK
|
docker pull mauriceboe/trek
|
||||||
docker rm -f 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.
|
> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||||
|
|||||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
|||||||
|
|
||||||
## Scope
|
## 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.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
+3
-1
@@ -15,11 +15,13 @@ This is a minimal Helm chart for deploying the TREK app.
|
|||||||
A hosted Helm repository is available:
|
A hosted Helm repository is available:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
helm repo add trek https://mauriceboe.github.io/TREK
|
helm repo add trek https://chart.liketrek.com
|
||||||
helm repo update
|
helm repo update
|
||||||
helm install trek trek/trek
|
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
|
## Usage
|
||||||
|
|
||||||
Or install directly from the local chart:
|
Or install directly from the local chart:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
image:
|
image:
|
||||||
repository: liketrek/TREK
|
repository: mauriceboe/trek
|
||||||
# tag: latest
|
# tag: latest
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export function GalleryView({ entries, gallery, journeyId, userId, trips, onPhot
|
|||||||
<div className="absolute top-1.5 left-1.5">
|
<div className="absolute top-1.5 left-1.5">
|
||||||
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
|
<span className="text-[8px] font-medium px-1.5 py-0.5 rounded-full bg-black/70 backdrop-blur text-white flex items-center gap-1">
|
||||||
<RefreshCw size={7} />
|
<RefreshCw size={7} />
|
||||||
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synology' ? 'Synology' : photo.provider}
|
{photo.provider === 'immich' ? 'Immich' : photo.provider === 'synologyphotos' ? 'Synology Photos' : photo.provider}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -100,6 +100,15 @@ describe('AirTrailImportModal', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-AIRTRAIL-011: opens after being mounted closed (#1602)', async () => {
|
||||||
|
// The modal stays mounted with isOpen=false until the AirTrail button is
|
||||||
|
// clicked — every hook must run on the closed renders too, or the open
|
||||||
|
// render throws "Rendered more hooks than during the previous render".
|
||||||
|
const { rerender } = render(<AirTrailImportModal {...defaultProps} isOpen={false} />);
|
||||||
|
rerender(<AirTrailImportModal {...defaultProps} isOpen={true} />);
|
||||||
|
expect(await screen.findByText('Import from AirTrail')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
|
it('FE-PLANNER-AIRTRAIL-004: offers to join a detected connection, on by default', async () => {
|
||||||
render(<AirTrailImportModal {...defaultProps} />);
|
render(<AirTrailImportModal {...defaultProps} />);
|
||||||
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
|
const joinRow = await screen.findByText(/one flight with a layover in HEL/i);
|
||||||
|
|||||||
@@ -142,6 +142,24 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [flights, trip?.start_date, trip?.end_date])
|
}, [flights, trip?.start_date, trip?.end_date])
|
||||||
|
|
||||||
|
// A chain renders once as a group, at the position of its first listed leg —
|
||||||
|
// its other legs are swallowed wherever else they would appear. Computed per
|
||||||
|
// section up front so a chain straddling the trip range can't leave the other
|
||||||
|
// section holding nothing but its header.
|
||||||
|
const sectionItems = useMemo(() => {
|
||||||
|
const rendered = new Set<string>()
|
||||||
|
const build = (list: AirtrailFlight[]) =>
|
||||||
|
list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
|
||||||
|
const chain = chainOf.get(f.id)
|
||||||
|
if (!chain) return [{ key: f.id, flight: f }]
|
||||||
|
const key = chain.map(c => c.id).join('+')
|
||||||
|
if (rendered.has(key)) return []
|
||||||
|
rendered.add(key)
|
||||||
|
return [{ key, chain }]
|
||||||
|
})
|
||||||
|
return { during: build(during), others: build(others) }
|
||||||
|
}, [during, others, chainOf])
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
const toggle = (id: string) => {
|
||||||
setSelected(prev => {
|
setSelected(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -286,24 +304,6 @@ export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A chain renders once as a group, at the position of its first listed leg —
|
|
||||||
// its other legs are swallowed wherever else they would appear. Computed per
|
|
||||||
// section up front so a chain straddling the trip range can't leave the other
|
|
||||||
// section holding nothing but its header.
|
|
||||||
const sectionItems = useMemo(() => {
|
|
||||||
const rendered = new Set<string>()
|
|
||||||
const build = (list: AirtrailFlight[]) =>
|
|
||||||
list.flatMap((f): Array<{ key: string; chain?: AirtrailFlight[]; flight?: AirtrailFlight }> => {
|
|
||||||
const chain = chainOf.get(f.id)
|
|
||||||
if (!chain) return [{ key: f.id, flight: f }]
|
|
||||||
const key = chain.map(c => c.id).join('+')
|
|
||||||
if (rendered.has(key)) return []
|
|
||||||
rendered.add(key)
|
|
||||||
return [{ key, chain }]
|
|
||||||
})
|
|
||||||
return { during: build(during), others: build(others) }
|
|
||||||
}, [during, others, chainOf])
|
|
||||||
|
|
||||||
const renderItem = (item: { chain?: AirtrailFlight[]; flight?: AirtrailFlight }) =>
|
const renderItem = (item: { chain?: AirtrailFlight[]; flight?: AirtrailFlight }) =>
|
||||||
item.chain ? renderChain(item.chain) : renderFlight(item.flight!)
|
item.chain ? renderChain(item.chain) : renderFlight(item.flight!)
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,22 @@ describe('TransitSearchPanel', () => {
|
|||||||
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
|
expect(transitApiMock.plan.mock.calls[0][0]).toMatchObject({ arriveBy: true, time: '2025-06-01T08:00:00.000Z' })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('FE-PLANNER-TRANSIT-009: arrive-by lists the latest-arriving itinerary first', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
// MOTIS returns arrive-by results ascending, deadline-adjacent last (#1479).
|
||||||
|
const later = { ...ITINERARY, startTime: '2025-06-01T07:10:00Z', endTime: '2025-06-01T07:40:00Z' }
|
||||||
|
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [ITINERARY, later] })
|
||||||
|
render(<TransitSearchPanel {...makeProps()} />)
|
||||||
|
await pickFromAndTo(user)
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Arrive' }))
|
||||||
|
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||||
|
await screen.findByText(/09:10 – 09:40/)
|
||||||
|
const cards = screen.getAllByText(/–/).filter(el => el.textContent?.match(/\d{2}:\d{2} – \d{2}:\d{2}/))
|
||||||
|
// The itinerary arriving closest to the requested arrival time leads.
|
||||||
|
expect(cards[0].textContent).toContain('09:40')
|
||||||
|
expect(cards[1].textContent).toContain('09:00')
|
||||||
|
})
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
|
it('FE-PLANNER-TRANSIT-006: swap exchanges from and to', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
render(<TransitSearchPanel {...makeProps()} />)
|
render(<TransitSearchPanel {...makeProps()} />)
|
||||||
|
|||||||
@@ -386,6 +386,10 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
|||||||
to: { ...l.to, name: cleanStop(l.to.name) },
|
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)
|
setItineraries(cleaned)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('transit.searchError'))
|
toast.error(t('transit.searchError'))
|
||||||
|
|||||||
@@ -2064,15 +2064,17 @@ describe('JourneyDetailPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── FE-PAGE-JOURNEYDETAIL-093 ──────────────────────────────────────────
|
// ── FE-PAGE-JOURNEYDETAIL-093 ──────────────────────────────────────────
|
||||||
describe('FE-PAGE-JOURNEYDETAIL-093: Gallery shows Synology badge for synology photos', () => {
|
describe('FE-PAGE-JOURNEYDETAIL-093: Gallery shows Synology badge for synologyphotos photos', () => {
|
||||||
it('renders "Synology" badge on photos from synology provider', async () => {
|
// 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 user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
|
||||||
const synologyEntry = {
|
const synologyEntry = {
|
||||||
...mockJourneyDetail.entries[0],
|
...mockJourneyDetail.entries[0],
|
||||||
photos: [{
|
photos: [{
|
||||||
id: 201, entry_id: 10, photo_id: 201, provider: 'synology', file_path: null,
|
id: 201, entry_id: 10, photo_id: 201, provider: 'synologyphotos', file_path: null,
|
||||||
asset_id: 'syn-456', owner_id: 1, thumbnail_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,
|
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]],
|
entries: [synologyEntry, mockJourneyDetail.entries[1]],
|
||||||
stats: { entries: 2, photos: 1, places: 2 },
|
stats: { entries: 2, photos: 1, places: 2 },
|
||||||
gallery: [{
|
gallery: [{
|
||||||
id: 201, journey_id: 1, photo_id: 201, provider: 'synology', file_path: null,
|
id: 201, journey_id: 1, photo_id: 201, provider: 'synologyphotos', file_path: null,
|
||||||
asset_id: 'syn-456', owner_id: 1, thumbnail_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,
|
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(/1 photos/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Synology')).toBeInTheDocument();
|
expect(screen.getByText('Synology Photos')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('synologyphotos')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
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 stop trek && docker rm trek
|
||||||
docker run -d --name trek \\
|
docker run -d --name trek \\
|
||||||
-p 3000:3000 \\
|
-p 3000:3000 \\
|
||||||
-v /opt/trek/data:/app/data \\
|
-v /opt/trek/data:/app/data \\
|
||||||
-v /opt/trek/uploads:/app/uploads \\
|
-v /opt/trek/uploads:/app/uploads \\
|
||||||
--restart unless-stopped \\
|
--restart unless-stopped \\
|
||||||
mauriceboe/TREK:latest`}
|
mauriceboe/trek:latest`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { normalizeRegionName } from './atlasModel';
|
import { A2_TO_A3, normalizeRegionName } from './atlasModel';
|
||||||
|
|
||||||
describe('normalizeRegionName', () => {
|
describe('normalizeRegionName', () => {
|
||||||
it('matches names that only differ by diacritics (Ile-de-France vs Île-de-France)', () => {
|
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'));
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -71,10 +71,11 @@ export function countryCodeToFlag(code: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ISO-3166-1 alpha-2 → alpha-3 mapping. Two sources feed this table:
|
// 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
|
// 1. Hardcoded entries below — REQUIRED for any country whose GeoJSON record has no
|
||||||
// has ISO_A2='-99' (e.g. France=FRA, Norway=NOR). The runtime augmentation loop
|
// usable ISO_A2: '-99' in Natural Earth data (e.g. France=FRA, Norway=NOR) or null
|
||||||
// (see geoData useEffect below) skips '-99' features, so those countries MUST be
|
// in the geoBoundaries bundle (Kosovo=XKX, a user-assigned ISO code, #1609). The
|
||||||
// listed here or the atlas_country_options A3-fallback will silently fail.
|
// 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
|
// 2. Runtime augmentation — the geoData load effect adds entries for every feature
|
||||||
// that has a valid ISO_A2, covering territories not present below.
|
// that has a valid ISO_A2, covering territories not present below.
|
||||||
export const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
|
export const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AG":"ATG","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BS":"BHS","BH":"BHR","BD":"BGD","BB":"BRB","BY":"BLR","BE":"BEL","BZ":"BLZ","BJ":"BEN","BT":"BTN","BO":"BOL","BA":"BIH","BW":"BWA","BR":"BRA","BN":"BRN","BG":"BGR","BF":"BFA","BI":"BDI","CV":"CPV","KH":"KHM","CM":"CMR","CA":"CAN","CF":"CAF","TD":"TCD","CL":"CHL","CN":"CHN","CO":"COL","KM":"COM","CG":"COG","CD":"COD","CR":"CRI","CI":"CIV","HR":"HRV","CU":"CUB","CY":"CYP","CZ":"CZE","DK":"DNK","DJ":"DJI","DM":"DMA","DO":"DOM","EC":"ECU","EG":"EGY","SV":"SLV","GQ":"GNQ","ER":"ERI","EE":"EST","SZ":"SWZ","ET":"ETH","FJ":"FJI","FI":"FIN","FR":"FRA","GA":"GAB","GM":"GMB","GE":"GEO","DE":"DEU","GH":"GHA","GR":"GRC","GD":"GRD","GT":"GTM","GN":"GIN","GW":"GNB","GY":"GUY","HT":"HTI","HN":"HND","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JM":"JAM","JP":"JPN","JO":"JOR","KZ":"KAZ","KE":"KEN","KI":"KIR","XK":"XKX","KP":"PRK","KR":"KOR","KW":"KWT","KG":"KGZ","LA":"LAO","LV":"LVA","LB":"LBN","LS":"LSO","LR":"LBR","LY":"LBY","LI":"LIE","LT":"LTU","LU":"LUX","MG":"MDG","MW":"MWI","MY":"MYS","MV":"MDV","ML":"MLI","MT":"MLT","MR":"MRT","MU":"MUS","MX":"MEX","MD":"MDA","MN":"MNG","ME":"MNE","MA":"MAR","MZ":"MOZ","MM":"MMR","NA":"NAM","NP":"NPL","NL":"NLD","NZ":"NZL","NI":"NIC","NE":"NER","NG":"NGA","MK":"MKD","NO":"NOR","OM":"OMN","PK":"PAK","PA":"PAN","PG":"PNG","PY":"PRY","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","QA":"QAT","RO":"ROU","RU":"RUS","RW":"RWA","SA":"SAU","SN":"SEN","RS":"SRB","SL":"SLE","SG":"SGP","SK":"SVK","SI":"SVN","SB":"SLB","SO":"SOM","ZA":"ZAF","SS":"SSD","ES":"ESP","LK":"LKA","SD":"SDN","SR":"SUR","SE":"SWE","CH":"CHE","SY":"SYR","TW":"TWN","TJ":"TJK","TZ":"TZA","TH":"THA","TL":"TLS","TG":"TGO","TT":"TTO","TN":"TUN","TR":"TUR","TM":"TKM","UG":"UGA","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","UY":"URY","UZ":"UZB","VU":"VUT","VE":"VEN","VN":"VNM","YE":"YEM","ZM":"ZMB","ZW":"ZWE"}
|
||||||
|
|||||||
@@ -178,13 +178,13 @@ describe('shouldDrawMorningLeg', () => {
|
|||||||
expect(shouldDrawMorningLeg(bookends, checkInDay, { isPlace: true, time: '15:00' })).toBe(true)
|
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)', () => {
|
it('does NOT draw on a check-in day when time info is missing (#1597)', () => {
|
||||||
// Without a comparable place time or hotel check-in time, the arrival day stays a
|
// An un-timed first place on an arrival day ("Home" before driving out) cannot prove
|
||||||
// home-base loop — only a place provably before check-in suppresses the leg.
|
// 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 }
|
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 }
|
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)', () => {
|
it('does NOT draw on a check-in day for a transport arrival (not a place, #1321)', () => {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// morning hotel (a normal home-base day). On that hotel's check-in day you were traveling TO the
|
||||||
// once you arrive, so the leg is still the default for a PLACE first stop — suppressed only when
|
// hotel, so the leg is drawn only when the first stop is a PLACE provably timed at/after check-in
|
||||||
// that place is timed BEFORE check-in (an airport you reach before dropping your bags, #1465), or
|
// (you dropped your bags first). A place before check-in (an airport you reach first, #1465), a
|
||||||
// when the first stop is a transport arrival (you flew in, weren't at the hotel yet, #1321).
|
// transport arrival (you flew in, weren't at the hotel yet, #1321), an un-timed place ("Home"
|
||||||
// Un-timed places keep the loop, avoiding over-suppression on ordinary arrival days.
|
// before driving out, #1597), or a missing check-in time all mean no leg — mirroring the
|
||||||
|
// evening rule below.
|
||||||
export const shouldDrawMorningLeg = (
|
export const shouldDrawMorningLeg = (
|
||||||
bookends: { morning?: Accommodation; morningIsSleptHere?: boolean },
|
bookends: { morning?: Accommodation; morningIsSleptHere?: boolean },
|
||||||
day: Day,
|
day: Day,
|
||||||
@@ -73,7 +74,7 @@ export const shouldDrawMorningLeg = (
|
|||||||
if (!m || m.start_day_id !== day.id || !firstStop?.isPlace) return false
|
if (!m || m.start_day_id !== day.id || !firstStop?.isPlace) return false
|
||||||
const checkIn = parseTimeToMinutes(m.check_in)
|
const checkIn = parseTimeToMinutes(m.check_in)
|
||||||
const stop = parseTimeToMinutes(firstStop.time)
|
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
|
// Mirror of shouldDrawMorningLeg for the last-stop → hotel evening leg. It is a real drive when
|
||||||
|
|||||||
@@ -295,15 +295,16 @@ describe('useRouteCalculation', () => {
|
|||||||
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
|
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 () => {
|
it('FE-HOOK-ROUTE-015: day-1 with a first activity timed after check-in keeps the hotel → first-activity leg', async () => {
|
||||||
// Guard against over-suppression: with no arrival transport, the check-in day is a
|
// The check-in day is still a home-base loop when the first activity provably happens
|
||||||
// home-base loop and the hotel → first-stop leg must remain.
|
// at/after check-in (you dropped your bags first) — the hotel → first-stop leg remains.
|
||||||
const actA = buildPlace({ lat: 41.38, lng: 2.17 });
|
// 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 actB = buildPlace({ lat: 41.40, lng: 2.19 });
|
||||||
const hotel = { lat: 41.39, lng: 2.16 };
|
const hotel = { lat: 41.39, lng: 2.16 };
|
||||||
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
|
const a1 = buildAssignment({ day_id: 1, order_index: 0, place: actA });
|
||||||
const a2 = buildAssignment({ day_id: 1, order_index: 1, place: actB });
|
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;
|
const store = { assignments: { '1': [a1, a2] } } as unknown as TripStoreState;
|
||||||
useTripStore.setState({
|
useTripStore.setState({
|
||||||
assignments: store.assignments,
|
assignments: store.assignments,
|
||||||
@@ -322,6 +323,47 @@ describe('useRouteCalculation', () => {
|
|||||||
expect(legs).toContainEqual([`${actB.lat},${actB.lng}`, `${hotel.lat},${hotel.lng}`]);
|
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 () => {
|
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
|
// 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.
|
// hotel B tonight, with no activities or transport. The map must still draw A → B.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/TREK:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: trek
|
container_name: trek
|
||||||
read_only: true
|
read_only: true
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|||||||
Binary file not shown.
@@ -42,11 +42,12 @@ const CONCURRENCY = 8
|
|||||||
const RETRIES = 3
|
const RETRIES = 3
|
||||||
|
|
||||||
// Complete ISO-3166-1 alpha-3 → alpha-2 map (source: lukes/ISO-3166-Countries-with-
|
// 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
|
// Regional-Codes, plus user-assigned XKX/XK for Kosovo, which geoBoundaries ships but
|
||||||
// 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries keys by alpha-3
|
// the upstream ISO list omits — #1609). Drives ADM1 enumeration (one gbOpen request per
|
||||||
// `shapeGroup`). A complete map — not the client's curated ~180 — is what restores the
|
// code; missing ones 404 and are skipped) and stamps `iso_a2`/`ISO_A2` (geoBoundaries
|
||||||
// dropped territories (Greenland, Falklands, French Guiana, …).
|
// keys by alpha-3 `shapeGroup`). A complete map — not the client's curated ~180 — is
|
||||||
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"}
|
// 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)
|
const COUNTRIES = Object.keys(A3_TO_A2) // every ISO alpha-3 code (ADM1 fetch list)
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ export interface RegistryEntry {
|
|||||||
authorPublicKey?: string;
|
authorPublicKey?: string;
|
||||||
/** Release-asset downloads across all versions, aggregated by the registry's stats cron. */
|
/** Release-asset downloads across all versions, aggregated by the registry's stats cron. */
|
||||||
downloadCount?: number | null;
|
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[];
|
versions: RegistryVersion[];
|
||||||
}
|
}
|
||||||
interface Registry {
|
interface Registry {
|
||||||
@@ -208,7 +215,7 @@ export class PluginRegistryService {
|
|||||||
minTrekVersion: latest?.minTrekVersion ?? null,
|
minTrekVersion: latest?.minTrekVersion ?? null,
|
||||||
requiredAddons: latest?.requiredAddons ?? [],
|
requiredAddons: latest?.requiredAddons ?? [],
|
||||||
pluginDependencies: latest?.pluginDependencies ?? [],
|
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,
|
signed: !!p.authorPublicKey && !!latest?.signature,
|
||||||
authorPublicKey: p.authorPublicKey ?? null,
|
authorPublicKey: p.authorPublicKey ?? null,
|
||||||
...this.hostCompat(p),
|
...this.hostCompat(p),
|
||||||
@@ -283,7 +290,7 @@ export class PluginRegistryService {
|
|||||||
publishedAt: latest?.publishedAt ?? null,
|
publishedAt: latest?.publishedAt ?? null,
|
||||||
requiredAddons: latest?.requiredAddons ?? [],
|
requiredAddons: latest?.requiredAddons ?? [],
|
||||||
pluginDependencies: latest?.pluginDependencies ?? [],
|
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,
|
signed: !!entry.authorPublicKey && !!latest?.signature,
|
||||||
authorPublicKey: entry.authorPublicKey ?? null,
|
authorPublicKey: entry.authorPublicKey ?? null,
|
||||||
...this.hostCompat(entry),
|
...this.hostCompat(entry),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { pipeline } from 'node:stream/promises';
|
|||||||
import { Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { canAccessTrip, db } from "../../db/database";
|
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';
|
import { decrypt_api_key } from '../apiKeyCrypto';
|
||||||
|
|
||||||
// helpers for handling return types
|
// 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);
|
db.prepare('UPDATE trip_album_links SET last_synced_at = CURRENT_TIMESTAMP WHERE id = ?').run(linkId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string): Promise<void> {
|
export async function pipeAsset(url: string, response: Response, headers?: Record<string, string>, signal?: AbortSignal, defaultCacheControl?: string, fetchOptions?: SafeFetchOptions): Promise<void> {
|
||||||
try {
|
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);
|
response.status(resp.status);
|
||||||
if (resp.headers.get('content-type')) response.set('Content-Type', resp.headers.get('content-type') as string);
|
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) {
|
if (error instanceof SsrfBlockedError) {
|
||||||
response.status(400).json({ error: error.message });
|
response.status(400).json({ error: error.message });
|
||||||
} else {
|
} 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' });
|
response.status(500).json({ error: 'Failed to fetch asset' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -638,12 +638,13 @@ export async function fetchSynologyThumbnailBytes(
|
|||||||
|
|
||||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
||||||
try {
|
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 };
|
if (!resp.ok) return { error: 'Upstream error', status: resp.status };
|
||||||
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
const contentType = resp.headers.get('content-type') || 'image/jpeg';
|
||||||
const bytes = Buffer.from(await resp.arrayBuffer());
|
const bytes = Buffer.from(await resp.arrayBuffer());
|
||||||
return { bytes, contentType };
|
return { bytes, contentType };
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error('fetchSynologyThumbnailBytes: upstream fetch failed:', error);
|
||||||
return { error: 'Proxy error', status: 502 };
|
return { error: 'Proxy error', status: 502 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -700,6 +701,6 @@ export async function streamSynologyAsset(
|
|||||||
if (passphrase) params.append('passphrase', passphrase);
|
if (passphrase) params.append('passphrase', passphrase);
|
||||||
|
|
||||||
const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString());
|
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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -492,16 +492,25 @@ export function getAdminNtfyConfig(): NtfyConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the ntfy POST URL from admin base config + user override.
|
* Resolve the ntfy POST URL for a per-user send. The topic must come from the
|
||||||
* Returns null if topic cannot be determined.
|
* 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 {
|
export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null): string | null {
|
||||||
const topic = userCfg?.topic || adminCfg.topic;
|
const topic = userCfg?.topic;
|
||||||
if (!topic) return null;
|
if (!topic) return null;
|
||||||
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
const base = (userCfg?.server || adminCfg.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||||
return `${base}/${encodeURIComponent(topic)}`;
|
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 {
|
function encodeHeaderValue(value: string): string {
|
||||||
for (let i = 0; i < value.length; i++) {
|
for (let i = 0; i < value.length; i++) {
|
||||||
if (value.charCodeAt(i) > 0xff) {
|
if (value.charCodeAt(i) > 0xff) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getUserNtfyConfig,
|
getUserNtfyConfig,
|
||||||
getAdminNtfyConfig,
|
getAdminNtfyConfig,
|
||||||
resolveNtfyUrl,
|
resolveNtfyUrl,
|
||||||
|
resolveAdminNtfyUrl,
|
||||||
} from '../notifications';
|
} from '../notifications';
|
||||||
import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry';
|
import { registerChannel, type ChannelMessage, type ExternalChannel } from './channelRegistry';
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ const ntfyChannel: ExternalChannel = {
|
|||||||
},
|
},
|
||||||
async sendGlobal(msg: ChannelMessage) {
|
async sendGlobal(msg: ChannelMessage) {
|
||||||
const adminCfg = getAdminNtfyConfig();
|
const adminCfg = getAdminNtfyConfig();
|
||||||
const url = resolveNtfyUrl(adminCfg, null);
|
const url = resolveAdminNtfyUrl(adminCfg);
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
return sendNtfy(url, adminCfg.token, { event: msg.event, title: msg.title, body: msg.body, link: msg.url });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
safeFetch: vi.fn().mockImplementation(makeFakeSynologyFetch),
|
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);
|
expect(decrypt_api_key(row.passphrase)).toBe(secondPass);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Skip-SSL forwarding on image-byte fetches (#1611) ─────────────────────────
|
||||||
|
|
||||||
|
describe('Synology skip-SSL forwarding to image fetches (#1611)', () => {
|
||||||
|
// Earlier tests queue mock*Once responses on safeFetch that are not always
|
||||||
|
// fully consumed — reset to the shared fake so they can't leak in here.
|
||||||
|
beforeEach(async () => {
|
||||||
|
const guard = await import('../../src/utils/ssrfGuard') as any;
|
||||||
|
vi.mocked(safeFetch).mockReset();
|
||||||
|
vi.mocked(safeFetch).mockImplementation(guard.__fakeSynologyFetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Unique asset id per run — the thumbnail disk cache keys on
|
||||||
|
// provider:asset:kind:owner and would otherwise serve a stale entry from a
|
||||||
|
// previous run instead of hitting safeFetch.
|
||||||
|
let assetSeq = 0;
|
||||||
|
const uniqueAssetId = () => `${Date.now()}${++assetSeq}_test1611`;
|
||||||
|
|
||||||
|
function createSynologyTrekPhoto(skipSsl: 0 | 1) {
|
||||||
|
const { user } = createUser(testDb);
|
||||||
|
setSynologyCredentials(testDb, user.id, 'https://synology.example.com', 'admin', 'pass');
|
||||||
|
testDb.prepare('UPDATE users SET synology_skip_ssl = ? WHERE id = ?').run(skipSsl, user.id);
|
||||||
|
const assetId = uniqueAssetId();
|
||||||
|
const insert = testDb.prepare(
|
||||||
|
'INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES (?, ?, ?)'
|
||||||
|
).run('synologyphotos', assetId, user.id);
|
||||||
|
return { user, trekPhotoId: Number(insert.lastInsertRowid) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbnailFetchCalls() {
|
||||||
|
return vi.mocked(safeFetch).mock.calls.filter(call => String(call[0]).includes('SYNO.Foto.Thumbnail'));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('SYNO-100 — thumbnail fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
|
||||||
|
const { user, trekPhotoId } = createSynologyTrekPhoto(1);
|
||||||
|
vi.mocked(safeFetch).mockClear();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/photos/${trekPhotoId}/thumbnail`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const calls = thumbnailFetchCalls();
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
for (const call of calls) {
|
||||||
|
expect(call[2]).toMatchObject({ rejectUnauthorized: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SYNO-101 — original fetch passes rejectUnauthorized: false when skip-SSL is enabled', async () => {
|
||||||
|
const { user, trekPhotoId } = createSynologyTrekPhoto(1);
|
||||||
|
vi.mocked(safeFetch).mockClear();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/photos/${trekPhotoId}/original`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const calls = thumbnailFetchCalls();
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
for (const call of calls) {
|
||||||
|
expect(call[2]).toMatchObject({ rejectUnauthorized: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SYNO-102 — image fetches verify TLS when skip-SSL is disabled', async () => {
|
||||||
|
const { user, trekPhotoId } = createSynologyTrekPhoto(0);
|
||||||
|
vi.mocked(safeFetch).mockClear();
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.get(`/api/photos/${trekPhotoId}/thumbnail`)
|
||||||
|
.set('Cookie', authCookie(user.id));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const calls = thumbnailFetchCalls();
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
for (const call of calls) {
|
||||||
|
expect(call[2]?.rejectUnauthorized ?? true).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -136,6 +136,23 @@ describe('PluginRegistryService', () => {
|
|||||||
expect(list[0].screenshotUrl).toBe(`https://raw.githubusercontent.com/acme/trek-flight/${'a'.repeat(40)}/docs/screenshot.png`);
|
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 () => {
|
it('detail merges registry metadata with a live manifest preview', async () => {
|
||||||
safeDownload.mockResolvedValue({
|
safeDownload.mockResolvedValue({
|
||||||
bytes: Buffer.from(JSON.stringify({
|
bytes: Buffer.from(JSON.stringify({
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import zlib from 'zlib';
|
|||||||
// "ESP", every Chinese "CHN", also CL/OM), which made marking one region light up the
|
// "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
|
// 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.
|
// 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)', () => {
|
describe('Atlas admin1 region bundle (#1217)', () => {
|
||||||
const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
const bundlePath = path.join(__dirname, '..', '..', '..', 'assets', 'atlas', 'admin1.geojson.gz');
|
||||||
const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
|
const features = JSON.parse(zlib.gunzipSync(fs.readFileSync(bundlePath)).toString()).features as {
|
||||||
|
|||||||
@@ -216,3 +216,61 @@ describe('pipeAsset', () => {
|
|||||||
expect(res.end).toHaveBeenCalled();
|
expect(res.end).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── pipeAsset fetch options (#1611) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('pipeAsset fetch options (#1611)', () => {
|
||||||
|
function mockResponse(overrides: Record<string, any> = {}) {
|
||||||
|
return {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
end: vi.fn(),
|
||||||
|
json: vi.fn(),
|
||||||
|
headersSent: false,
|
||||||
|
...overrides,
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('MEM-HELPERS-021: forwards fetchOptions to safeFetch', async () => {
|
||||||
|
mockSafeFetch.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: vi.fn(() => null) },
|
||||||
|
body: null,
|
||||||
|
});
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
await pipeAsset('https://example.com/asset', res, undefined, undefined, undefined, { rejectUnauthorized: false });
|
||||||
|
|
||||||
|
expect(mockSafeFetch).toHaveBeenCalledWith(
|
||||||
|
'https://example.com/asset',
|
||||||
|
expect.anything(),
|
||||||
|
{ rejectUnauthorized: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MEM-HELPERS-022: omitting fetchOptions leaves safeFetch options undefined', async () => {
|
||||||
|
mockSafeFetch.mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
headers: { get: vi.fn(() => null) },
|
||||||
|
body: null,
|
||||||
|
});
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
await pipeAsset('https://example.com/asset', res);
|
||||||
|
|
||||||
|
expect(mockSafeFetch.mock.calls[0][2]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MEM-HELPERS-023: logs the underlying error when responding 500', async () => {
|
||||||
|
const boom = new Error('unable to verify the first certificate');
|
||||||
|
mockSafeFetch.mockRejectedValue(boom);
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const res = mockResponse({ headersSent: false });
|
||||||
|
|
||||||
|
await pipeAsset('https://example.com/asset', res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(expect.any(String), boom);
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -527,7 +527,7 @@ describe('send() — ntfy channel dispatch', () => {
|
|||||||
it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
|
it('NTFY-SVCB-003 — ntfy skips when user has no topic configured', async () => {
|
||||||
const { user } = createUser(testDb);
|
const { user } = createUser(testDb);
|
||||||
setNotificationChannels(testDb, 'ntfy');
|
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();
|
fetchMock.mockClear();
|
||||||
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
|
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);
|
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 () => {
|
it('NTFY-SVCB-004 — admin-scoped version_available fires admin ntfy topic', async () => {
|
||||||
createAdmin(testDb);
|
createAdmin(testDb);
|
||||||
setAdminNtfyTopic();
|
setAdminNtfyTopic();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
|
|||||||
createPinnedDispatcher: vi.fn(() => ({})),
|
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 { checkSsrf } from '../../../src/utils/ssrfGuard';
|
||||||
import { logError } from '../../../src/services/auditLog';
|
import { logError } from '../../../src/services/auditLog';
|
||||||
|
|
||||||
@@ -325,11 +325,16 @@ afterAll(() => vi.unstubAllGlobals());
|
|||||||
describe('resolveNtfyUrl', () => {
|
describe('resolveNtfyUrl', () => {
|
||||||
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
|
const adminCfg: NtfyConfig = { server: 'https://ntfy.sh', topic: 'admin-topic', token: null };
|
||||||
|
|
||||||
it('uses admin server + admin topic when no user config', () => {
|
it('returns null when no user config, even when admin topic is set (#1608)', () => {
|
||||||
expect(resolveNtfyUrl(adminCfg, null)).toBe('https://ntfy.sh/admin-topic');
|
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 };
|
const user: NtfyConfig = { server: null, topic: 'my-topic', token: null };
|
||||||
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic');
|
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.sh/my-topic');
|
||||||
});
|
});
|
||||||
@@ -340,18 +345,32 @@ describe('resolveNtfyUrl', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('strips trailing slash from server', () => {
|
it('strips trailing slash from server', () => {
|
||||||
const admin: NtfyConfig = { server: 'https://ntfy.sh/', topic: 'alerts', token: null };
|
const user: NtfyConfig = { server: 'https://ntfy.example.com/', topic: 'alerts', token: null };
|
||||||
expect(resolveNtfyUrl(admin, null)).toBe('https://ntfy.sh/alerts');
|
expect(resolveNtfyUrl(adminCfg, user)).toBe('https://ntfy.example.com/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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to https://ntfy.sh when no server configured', () => {
|
it('falls back to https://ntfy.sh when no server configured', () => {
|
||||||
const noServer: NtfyConfig = { server: null, topic: 'my-topic', token: null };
|
const noServer: NtfyConfig = { server: null, topic: null, token: null };
|
||||||
expect(resolveNtfyUrl(noServer, null)).toBe('https://ntfy.sh/my-topic');
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'صور',
|
'memories.title': 'صور',
|
||||||
'memories.notConnected': 'Immich غير متصل',
|
'memories.notConnected': '{provider_name} غير متصل',
|
||||||
'memories.notConnectedHint': 'قم بتوصيل Immich في الإعدادات لعرض صور رحلتك هنا.',
|
'memories.notConnectedHint': 'قم بتوصيل {provider_name} في الإعدادات لعرض صور رحلتك هنا.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
|
'قم بتوصيل أحد موفري الصور هؤلاء: {provider_names} في الإعدادات لتتمكن من إضافة صور إلى هذه الرحلة.',
|
||||||
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
|
'memories.noDates': 'أضف تواريخ لرحلتك لتحميل الصور.',
|
||||||
'memories.noPhotos': 'لم يتم العثور على صور',
|
'memories.noPhotos': 'لم يتم العثور على صور',
|
||||||
'memories.noPhotosHint': 'لم يتم العثور على صور في Immich لفترة هذه الرحلة.',
|
'memories.noPhotosHint': 'لم يتم العثور على صور في {provider_name} لفترة هذه الرحلة.',
|
||||||
'memories.photosFound': 'صور',
|
'memories.photosFound': 'صور',
|
||||||
'memories.fromOthers': 'من آخرين',
|
'memories.fromOthers': 'من آخرين',
|
||||||
'memories.sharePhotos': 'مشاركة الصور',
|
'memories.sharePhotos': 'مشاركة الصور',
|
||||||
@@ -29,20 +29,20 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'اختبر الاتصال أولاً',
|
'memories.testFirst': 'اختبر الاتصال أولاً',
|
||||||
'memories.connected': 'متصل',
|
'memories.connected': 'متصل',
|
||||||
'memories.disconnected': 'غير متصل',
|
'memories.disconnected': 'غير متصل',
|
||||||
'memories.connectionSuccess': 'تم الاتصال بـ Immich',
|
'memories.connectionSuccess': 'تم الاتصال بـ {provider_name}',
|
||||||
'memories.connectionError': 'تعذر الاتصال بـ Immich',
|
'memories.connectionError': 'تعذر الاتصال بـ {provider_name}',
|
||||||
'memories.saved': 'تم حفظ إعدادات {provider_name}',
|
'memories.saved': 'تم حفظ إعدادات {provider_name}',
|
||||||
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
|
'memories.providerDisconnectedBanner': 'اتصالك بـ {provider_name} مفقود. أعد الاتصال في الإعدادات لعرض الصور.',
|
||||||
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
|
'memories.saveError': 'تعذّر حفظ إعدادات {provider_name}',
|
||||||
'memories.addPhotos': 'إضافة صور',
|
'memories.addPhotos': 'إضافة صور',
|
||||||
'memories.linkAlbum': 'ربط ألبوم',
|
'memories.linkAlbum': 'ربط ألبوم',
|
||||||
'memories.selectAlbum': 'اختيار ألبوم Immich',
|
'memories.selectAlbum': 'اختيار ألبوم {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'اختيار ألبوم',
|
'memories.selectAlbumMultiple': 'اختيار ألبوم',
|
||||||
'memories.noAlbums': 'لم يتم العثور على ألبومات',
|
'memories.noAlbums': 'لم يتم العثور على ألبومات',
|
||||||
'memories.syncAlbum': 'مزامنة الألبوم',
|
'memories.syncAlbum': 'مزامنة الألبوم',
|
||||||
'memories.unlinkAlbum': 'إلغاء الربط',
|
'memories.unlinkAlbum': 'إلغاء الربط',
|
||||||
'memories.photos': 'صور',
|
'memories.photos': 'صور',
|
||||||
'memories.selectPhotos': 'اختيار صور من Immich',
|
'memories.selectPhotos': 'اختيار صور من {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'اختيار الصور',
|
'memories.selectPhotosMultiple': 'اختيار الصور',
|
||||||
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
'memories.selectHint': 'انقر على الصور لتحديدها.',
|
||||||
'memories.selected': 'محدد',
|
'memories.selected': 'محدد',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotos',
|
'memories.title': 'Fotos',
|
||||||
'memories.notConnected': 'Immich não conectado',
|
'memories.notConnected': '{provider_name} não conectado',
|
||||||
'memories.notConnectedHint': 'Conecte sua instância Immich nas Configurações para ver suas fotos de viagem aqui.',
|
'memories.notConnectedHint': 'Conecte sua instância {provider_name} nas Configurações para ver suas fotos de viagem aqui.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Conecte um destes provedores de fotos: {provider_names} nas Configurações para poder adicionar fotos a esta viagem.',
|
'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.noDates': 'Adicione datas à sua viagem para carregar fotos.',
|
||||||
'memories.noPhotos': 'Nenhuma foto encontrada',
|
'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.photosFound': 'fotos',
|
||||||
'memories.fromOthers': 'de outros',
|
'memories.fromOthers': 'de outros',
|
||||||
'memories.sharePhotos': 'Compartilhar fotos',
|
'memories.sharePhotos': 'Compartilhar fotos',
|
||||||
@@ -29,21 +29,21 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Teste a conexão primeiro',
|
'memories.testFirst': 'Teste a conexão primeiro',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'Não conectado',
|
'memories.disconnected': 'Não conectado',
|
||||||
'memories.connectionSuccess': 'Conectado ao Immich',
|
'memories.connectionSuccess': 'Conectado a {provider_name}',
|
||||||
'memories.connectionError': 'Não foi possível conectar ao Immich',
|
'memories.connectionError': 'Não foi possível conectar a {provider_name}',
|
||||||
'memories.saved': 'Configurações do {provider_name} salvas',
|
'memories.saved': 'Configurações do {provider_name} salvas',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Sua conexão com {provider_name} foi perdida. Reconecte nas Configurações para ver as fotos.',
|
'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.saveError': 'Não foi possível salvar as configurações de {provider_name}',
|
||||||
'memories.addPhotos': 'Adicionar fotos',
|
'memories.addPhotos': 'Adicionar fotos',
|
||||||
'memories.linkAlbum': 'Vincular álbum',
|
'memories.linkAlbum': 'Vincular álbum',
|
||||||
'memories.selectAlbum': 'Selecionar álbum do Immich',
|
'memories.selectAlbum': 'Selecionar álbum de {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Selecionar álbum',
|
'memories.selectAlbumMultiple': 'Selecionar álbum',
|
||||||
'memories.noAlbums': 'Nenhum álbum encontrado',
|
'memories.noAlbums': 'Nenhum álbum encontrado',
|
||||||
'memories.syncAlbum': 'Sincronizar álbum',
|
'memories.syncAlbum': 'Sincronizar álbum',
|
||||||
'memories.unlinkAlbum': 'Desvincular',
|
'memories.unlinkAlbum': 'Desvincular',
|
||||||
'memories.photos': 'fotos',
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': 'Selecionar fotos do Immich',
|
'memories.selectPhotos': 'Selecionar fotos de {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Selecionar fotos',
|
'memories.selectPhotosMultiple': 'Selecionar fotos',
|
||||||
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
|
'memories.selectHint': 'Toque nas fotos para selecioná-las.',
|
||||||
'memories.selected': 'selecionadas',
|
'memories.selected': 'selecionadas',
|
||||||
|
|||||||
@@ -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:",
|
"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.reloadHint': 'Recarrega la pàgina en uns segons.',
|
||||||
'admin.update.nonDockerText':
|
'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.update.wikiLink': "Obre la guia d\\'actualització",
|
||||||
'admin.addons.catalog.memories.name': 'Fotos (Immich)',
|
'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",
|
'admin.addons.catalog.memories.description': "Comparteix fotos de viatge a través de la teva instància d'Immich",
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotos',
|
'memories.title': 'Fotos',
|
||||||
'memories.notConnected': 'Immich no connectat',
|
'memories.notConnected': '{provider_name} no connectat',
|
||||||
'memories.notConnectedHint':
|
'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':
|
'memories.notConnectedMultipleHint':
|
||||||
"Connecta algun d'aquests proveïdors de fotos: {provider_names} a Configuració per poder afegir fotos a aquest viatge.",
|
"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.noDates': 'Afegeix dates al teu viatge per carregar fotos.',
|
||||||
'memories.noPhotos': "No s'han trobat 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.photosFound': 'fotos',
|
||||||
'memories.fromOthers': "d'altres",
|
'memories.fromOthers': "d'altres",
|
||||||
'memories.sharePhotos': 'Comparteix fotos',
|
'memories.sharePhotos': 'Comparteix fotos',
|
||||||
@@ -30,8 +30,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Prova primer la connexió',
|
'memories.testFirst': 'Prova primer la connexió',
|
||||||
'memories.connected': 'Connectat',
|
'memories.connected': 'Connectat',
|
||||||
'memories.disconnected': 'No connectat',
|
'memories.disconnected': 'No connectat',
|
||||||
'memories.connectionSuccess': 'Connectat a Immich',
|
'memories.connectionSuccess': 'Connectat a {provider_name}',
|
||||||
'memories.connectionError': "No s'ha pogut connectar a Immich",
|
'memories.connectionError': "No s'ha pogut connectar a {provider_name}",
|
||||||
'memories.saved': 'Configuració de {provider_name} desada',
|
'memories.saved': 'Configuració de {provider_name} desada',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
"S'ha perdut la connexió amb {provider_name}. Torna a connectar a Configuració per veure les fotos.",
|
"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.allLocations': 'Totes les ubicacions',
|
||||||
'memories.addPhotos': 'Afegeix fotos',
|
'memories.addPhotos': 'Afegeix fotos',
|
||||||
'memories.linkAlbum': 'Vincula un àlbum',
|
'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.selectAlbumMultiple': 'Selecciona un àlbum',
|
||||||
'memories.noAlbums': "No s'han trobat àlbums",
|
'memories.noAlbums': "No s'han trobat àlbums",
|
||||||
'memories.syncAlbum': "Sincronitza l'àlbum",
|
'memories.syncAlbum': "Sincronitza l'àlbum",
|
||||||
'memories.unlinkAlbum': 'Desvincula',
|
'memories.unlinkAlbum': 'Desvincula',
|
||||||
'memories.photos': 'fotos',
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': "Selecciona fotos d'Immich",
|
'memories.selectPhotos': "Selecciona fotos de {provider_name}",
|
||||||
'memories.selectPhotosMultiple': 'Selecciona fotos',
|
'memories.selectPhotosMultiple': 'Selecciona fotos',
|
||||||
'memories.selectHint': 'Toca les fotos per seleccionar-les.',
|
'memories.selectHint': 'Toca les fotos per seleccionar-les.',
|
||||||
'memories.selected': 'seleccionat(s)',
|
'memories.selected': 'seleccionat(s)',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotky',
|
'memories.title': 'Fotky',
|
||||||
'memories.notConnected': 'Immich není připojen',
|
'memories.notConnected': '{provider_name} není připojen',
|
||||||
'memories.notConnectedHint': 'Připojte svoji instanci Immich v Nastavení, abyste zde viděli fotky z cest.',
|
'memories.notConnectedHint': 'Připojte svoji instanci {provider_name} v Nastavení, abyste zde viděli fotky z cest.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Pro přidání fotek k tomuto výletu připojte v Nastavení jednoho z těchto poskytovatelů fotek: {provider_names}.',
|
'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.noDates': 'Přidejte data k cestě pro načtení fotek.',
|
||||||
'memories.noPhotos': 'Nenalezeny žádné fotky',
|
'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.photosFound': 'fotek',
|
||||||
'memories.fromOthers': 'od ostatních',
|
'memories.fromOthers': 'od ostatních',
|
||||||
'memories.sharePhotos': 'Sdílet fotky',
|
'memories.sharePhotos': 'Sdílet fotky',
|
||||||
@@ -29,21 +29,21 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Nejprve otestujte připojení',
|
'memories.testFirst': 'Nejprve otestujte připojení',
|
||||||
'memories.connected': 'Připojeno',
|
'memories.connected': 'Připojeno',
|
||||||
'memories.disconnected': 'Nepřipojeno',
|
'memories.disconnected': 'Nepřipojeno',
|
||||||
'memories.connectionSuccess': 'Připojeno k Immich',
|
'memories.connectionSuccess': 'Připojeno k {provider_name}',
|
||||||
'memories.connectionError': 'Nepodařilo se připojit k Immich',
|
'memories.connectionError': 'Nepodařilo se připojit k {provider_name}',
|
||||||
'memories.saved': 'Nastavení {provider_name} uloženo',
|
'memories.saved': 'Nastavení {provider_name} uloženo',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Vaše připojení k {provider_name} bylo ztraceno. Obnovte připojení v Nastavení pro zobrazení fotek.',
|
'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.saveError': 'Nepodařilo se uložit nastavení {provider_name}',
|
||||||
'memories.addPhotos': 'Přidat fotky',
|
'memories.addPhotos': 'Přidat fotky',
|
||||||
'memories.linkAlbum': 'Propojit album',
|
'memories.linkAlbum': 'Propojit album',
|
||||||
'memories.selectAlbum': 'Vybrat album z Immich',
|
'memories.selectAlbum': 'Vybrat album z {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Vybrat album',
|
'memories.selectAlbumMultiple': 'Vybrat album',
|
||||||
'memories.noAlbums': 'Žádná alba nenalezena',
|
'memories.noAlbums': 'Žádná alba nenalezena',
|
||||||
'memories.syncAlbum': 'Synchronizovat album',
|
'memories.syncAlbum': 'Synchronizovat album',
|
||||||
'memories.unlinkAlbum': 'Odpojit',
|
'memories.unlinkAlbum': 'Odpojit',
|
||||||
'memories.photos': 'fotek',
|
'memories.photos': 'fotek',
|
||||||
'memories.selectPhotos': 'Vybrat fotky z Immich',
|
'memories.selectPhotos': 'Vybrat fotky z {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Vybrat fotky',
|
'memories.selectPhotosMultiple': 'Vybrat fotky',
|
||||||
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
|
'memories.selectHint': 'Klepněte na fotky pro jejich výběr.',
|
||||||
'memories.selected': 'vybráno',
|
'memories.selected': 'vybráno',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotos',
|
'memories.title': 'Fotos',
|
||||||
'memories.notConnected': 'Immich nicht verbunden',
|
'memories.notConnected': '{provider_name} nicht verbunden',
|
||||||
'memories.notConnectedHint': 'Verbinde deine Immich-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
'memories.notConnectedHint': 'Verbinde deine {provider_name}-Instanz in den Einstellungen, um deine Reisefotos hier zu sehen.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Verbinde einen dieser Fotoanbieter: {provider_names} in den Einstellungen, um Fotos zu dieser Reise hinzufügen zu können.',
|
'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.noDates': 'Füge Daten zu deiner Reise hinzu, um Fotos zu laden.',
|
||||||
'memories.noPhotos': 'Keine Fotos gefunden',
|
'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.photosFound': 'Fotos',
|
||||||
'memories.fromOthers': 'von anderen',
|
'memories.fromOthers': 'von anderen',
|
||||||
'memories.sharePhotos': 'Fotos teilen',
|
'memories.sharePhotos': 'Fotos teilen',
|
||||||
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Verbindung zuerst testen',
|
'memories.testFirst': 'Verbindung zuerst testen',
|
||||||
'memories.connected': 'Verbunden',
|
'memories.connected': 'Verbunden',
|
||||||
'memories.disconnected': 'Nicht verbunden',
|
'memories.disconnected': 'Nicht verbunden',
|
||||||
'memories.connectionSuccess': 'Verbindung zu Immich hergestellt',
|
'memories.connectionSuccess': 'Verbindung zu {provider_name} hergestellt',
|
||||||
'memories.connectionError': 'Verbindung zu Immich fehlgeschlagen',
|
'memories.connectionError': 'Verbindung zu {provider_name} fehlgeschlagen',
|
||||||
'memories.saved': '{provider_name}-Einstellungen gespeichert',
|
'memories.saved': '{provider_name}-Einstellungen gespeichert',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Deine {provider_name}-Verbindung wurde getrennt. Verbinde erneut in den Einstellungen, um Fotos anzuzeigen.',
|
'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.fillRequiredFields': 'Bitte füllen Sie alle Pflichtfelder aus',
|
||||||
'memories.addPhotos': 'Fotos hinzufügen',
|
'memories.addPhotos': 'Fotos hinzufügen',
|
||||||
'memories.linkAlbum': 'Album verknüpfen',
|
'memories.linkAlbum': 'Album verknüpfen',
|
||||||
'memories.selectAlbum': 'Immich-Album auswählen',
|
'memories.selectAlbum': '{provider_name}-Album auswählen',
|
||||||
'memories.selectAlbumMultiple': 'Album auswählen',
|
'memories.selectAlbumMultiple': 'Album auswählen',
|
||||||
'memories.noAlbums': 'Keine Alben gefunden',
|
'memories.noAlbums': 'Keine Alben gefunden',
|
||||||
'memories.syncAlbum': 'Album synchronisieren',
|
'memories.syncAlbum': 'Album synchronisieren',
|
||||||
'memories.unlinkAlbum': 'Album trennen',
|
'memories.unlinkAlbum': 'Album trennen',
|
||||||
'memories.photos': 'Fotos',
|
'memories.photos': 'Fotos',
|
||||||
'memories.selectPhotos': 'Fotos aus Immich auswählen',
|
'memories.selectPhotos': 'Fotos aus {provider_name} auswählen',
|
||||||
'memories.selectPhotosMultiple': 'Fotos auswählen',
|
'memories.selectPhotosMultiple': 'Fotos auswählen',
|
||||||
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
'memories.selectHint': 'Tippe auf Fotos um sie auszuwählen.',
|
||||||
'memories.selected': 'ausgewählt',
|
'memories.selected': 'ausgewählt',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotos',
|
'memories.title': 'Fotos',
|
||||||
'memories.notConnected': 'Immich no conectado',
|
'memories.notConnected': '{provider_name} no conectado',
|
||||||
'memories.notConnectedHint': 'Conecta tu instancia de Immich en Ajustes para ver tus fotos de viaje aquí.',
|
'memories.notConnectedHint': 'Conecta tu instancia de {provider_name} en Ajustes para ver tus fotos de viaje aquí.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Conecta alguno de estos proveedores de fotos: {provider_names} en Configuración para poder añadir fotos a este viaje.',
|
'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.noDates': 'Añade fechas a tu viaje para cargar fotos.',
|
||||||
'memories.noPhotos': 'No se encontraron 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.photosFound': 'fotos',
|
||||||
'memories.fromOthers': 'de otros',
|
'memories.fromOthers': 'de otros',
|
||||||
'memories.sharePhotos': 'Compartir fotos',
|
'memories.sharePhotos': 'Compartir fotos',
|
||||||
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Probar conexión primero',
|
'memories.testFirst': 'Probar conexión primero',
|
||||||
'memories.connected': 'Conectado',
|
'memories.connected': 'Conectado',
|
||||||
'memories.disconnected': 'No conectado',
|
'memories.disconnected': 'No conectado',
|
||||||
'memories.connectionSuccess': 'Conectado a Immich',
|
'memories.connectionSuccess': 'Conectado a {provider_name}',
|
||||||
'memories.connectionError': 'No se pudo conectar a Immich',
|
'memories.connectionError': 'No se pudo conectar a {provider_name}',
|
||||||
'memories.saved': 'Configuración de {provider_name} guardada',
|
'memories.saved': 'Configuración de {provider_name} guardada',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Se perdió la conexión con {provider_name}. Vuelve a conectar en Configuración para ver las fotos.',
|
'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.allLocations': 'Todas las ubicaciones',
|
||||||
'memories.addPhotos': 'Añadir fotos',
|
'memories.addPhotos': 'Añadir fotos',
|
||||||
'memories.linkAlbum': 'Vincular álbum',
|
'memories.linkAlbum': 'Vincular álbum',
|
||||||
'memories.selectAlbum': 'Seleccionar álbum de Immich',
|
'memories.selectAlbum': 'Seleccionar álbum de {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Seleccionar álbum',
|
'memories.selectAlbumMultiple': 'Seleccionar álbum',
|
||||||
'memories.noAlbums': 'No se encontraron álbumes',
|
'memories.noAlbums': 'No se encontraron álbumes',
|
||||||
'memories.syncAlbum': 'Sincronizar álbum',
|
'memories.syncAlbum': 'Sincronizar álbum',
|
||||||
'memories.unlinkAlbum': 'Desvincular',
|
'memories.unlinkAlbum': 'Desvincular',
|
||||||
'memories.photos': 'fotos',
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': 'Seleccionar fotos de Immich',
|
'memories.selectPhotos': 'Seleccionar fotos de {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Seleccionar fotos',
|
'memories.selectPhotosMultiple': 'Seleccionar fotos',
|
||||||
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
'memories.selectHint': 'Toca las fotos para seleccionarlas.',
|
||||||
'memories.selected': 'seleccionado(s)',
|
'memories.selected': 'seleccionado(s)',
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Photos',
|
'memories.title': 'Photos',
|
||||||
'memories.notConnected': 'Immich non connecté',
|
'memories.notConnected': '{provider_name} non connecté',
|
||||||
'memories.notConnectedHint':
|
'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':
|
'memories.notConnectedMultipleHint':
|
||||||
'Connectez un de ces fournisseurs de photos : {provider_names} dans les Paramètres pour pouvoir ajouter des photos à ce voyage.',
|
'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.noDates': 'Ajoutez des dates à votre voyage pour charger les photos.',
|
||||||
'memories.noPhotos': 'Aucune photo trouvée',
|
'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.photosFound': 'photos',
|
||||||
'memories.fromOthers': "d'autres",
|
'memories.fromOthers': "d'autres",
|
||||||
'memories.sharePhotos': 'Partager les photos',
|
'memories.sharePhotos': 'Partager les photos',
|
||||||
@@ -31,8 +31,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
'memories.testFirst': 'Testez la connexion avant de sauvegarder',
|
||||||
'memories.connected': 'Connecté',
|
'memories.connected': 'Connecté',
|
||||||
'memories.disconnected': 'Non connecté',
|
'memories.disconnected': 'Non connecté',
|
||||||
'memories.connectionSuccess': 'Connecté à Immich',
|
'memories.connectionSuccess': 'Connecté à {provider_name}',
|
||||||
'memories.connectionError': 'Impossible de se connecter à Immich',
|
'memories.connectionError': 'Impossible de se connecter à {provider_name}',
|
||||||
'memories.saved': 'Paramètres {provider_name} enregistrés',
|
'memories.saved': 'Paramètres {provider_name} enregistrés',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Votre connexion {provider_name} est perdue. Reconnectez-vous dans les Paramètres pour voir les photos.',
|
'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.allLocations': 'Tous les lieux',
|
||||||
'memories.addPhotos': 'Ajouter des photos',
|
'memories.addPhotos': 'Ajouter des photos',
|
||||||
'memories.linkAlbum': 'Lier un album',
|
'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.selectAlbumMultiple': 'Sélectionner un album',
|
||||||
'memories.noAlbums': 'Aucun album trouvé',
|
'memories.noAlbums': 'Aucun album trouvé',
|
||||||
'memories.syncAlbum': 'Synchroniser',
|
'memories.syncAlbum': 'Synchroniser',
|
||||||
'memories.unlinkAlbum': 'Délier',
|
'memories.unlinkAlbum': 'Délier',
|
||||||
'memories.photos': 'photos',
|
'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.selectPhotosMultiple': 'Sélectionner des photos',
|
||||||
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
'memories.selectHint': 'Appuyez sur les photos pour les sélectionner.',
|
||||||
'memories.selected': 'sélectionné(s)',
|
'memories.selected': 'sélectionné(s)',
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Fotók',
|
'memories.title': 'Fotók',
|
||||||
'memories.notConnected': 'Immich nincs csatlakoztatva',
|
'memories.notConnected': '{provider_name} nincs csatlakoztatva',
|
||||||
'memories.notConnectedHint':
|
'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':
|
'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}.',
|
'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.noDates': 'Adj hozzá dátumokat az utazáshoz a fotók betöltéséhez.',
|
||||||
'memories.noPhotos': 'Nem találhatók fotók',
|
'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.photosFound': 'fotó',
|
||||||
'memories.fromOthers': 'másoktól',
|
'memories.fromOthers': 'másoktól',
|
||||||
'memories.sharePhotos': 'Fotók megosztása',
|
'memories.sharePhotos': 'Fotók megosztása',
|
||||||
@@ -31,21 +31,21 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
'memories.testFirst': 'Először teszteld a kapcsolatot',
|
||||||
'memories.connected': 'Csatlakoztatva',
|
'memories.connected': 'Csatlakoztatva',
|
||||||
'memories.disconnected': 'Nincs csatlakoztatva',
|
'memories.disconnected': 'Nincs csatlakoztatva',
|
||||||
'memories.connectionSuccess': 'Csatlakozva az Immichhez',
|
'memories.connectionSuccess': 'Csatlakozva a(z) {provider_name} szolgáltatáshoz',
|
||||||
'memories.connectionError': 'Nem sikerült csatlakozni az Immichhez',
|
'memories.connectionError': 'Nem sikerült csatlakozni a(z) {provider_name} szolgáltatáshoz',
|
||||||
'memories.saved': '{provider_name} beállítások mentve',
|
'memories.saved': '{provider_name} beállítások mentve',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'A {provider_name} kapcsolat megszakadt. Csatlakozzon újra a Beállításokban a fényképek megtekintéséhez.',
|
'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.saveError': 'Nem sikerült menteni a(z) {provider_name} beállításait',
|
||||||
'memories.addPhotos': 'Fotók hozzáadása',
|
'memories.addPhotos': 'Fotók hozzáadása',
|
||||||
'memories.linkAlbum': 'Album csatolá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.selectAlbumMultiple': 'Album kiválasztása',
|
||||||
'memories.noAlbums': 'Nem található album',
|
'memories.noAlbums': 'Nem található album',
|
||||||
'memories.syncAlbum': 'Album szinkronizálása',
|
'memories.syncAlbum': 'Album szinkronizálása',
|
||||||
'memories.unlinkAlbum': 'Leválasztás',
|
'memories.unlinkAlbum': 'Leválasztás',
|
||||||
'memories.photos': 'fotó',
|
'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.selectPhotosMultiple': 'Fényképek kiválasztása',
|
||||||
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
|
'memories.selectHint': 'Koppints a fotókra a kijelölésükhöz.',
|
||||||
'memories.selected': 'kijelölve',
|
'memories.selected': 'kijelölve',
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import ar from './ar';
|
||||||
|
import br from './br';
|
||||||
|
import ca from './ca';
|
||||||
|
import cs from './cs';
|
||||||
|
import de from './de';
|
||||||
|
import en from './en';
|
||||||
|
import es from './es';
|
||||||
|
import fr from './fr';
|
||||||
|
import gr from './gr';
|
||||||
|
import hu from './hu';
|
||||||
|
import id from './id';
|
||||||
|
import itIT from './it';
|
||||||
|
import ja from './ja';
|
||||||
|
import ko from './ko';
|
||||||
|
import nl from './nl';
|
||||||
|
import pl from './pl';
|
||||||
|
import ru from './ru';
|
||||||
|
import sv from './sv';
|
||||||
|
import tr from './tr';
|
||||||
|
import type { TranslationStrings } from './types';
|
||||||
|
import uk from './uk';
|
||||||
|
import vi from './vi';
|
||||||
|
import zh from './zh';
|
||||||
|
import zhTW from './zh-TW';
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder parity: every `{placeholder}` present in an EN string must also
|
||||||
|
* appear (untranslated) in each locale's translation of that key.
|
||||||
|
*
|
||||||
|
* This guards against the class of bug behind issue #1611's "Failed to connect
|
||||||
|
* to Immich" report: locales that hardcoded a provider name where EN uses
|
||||||
|
* `{provider_name}`, so the Synology banner showed "Immich". It also catches
|
||||||
|
* translated placeholder names (e.g. `{versiyon}`), which render literally.
|
||||||
|
*
|
||||||
|
* Only keys the locale actually translates are checked — missing keys are
|
||||||
|
* key-set drift, handled (as unenforced data) by i18n-parity.spec.ts.
|
||||||
|
*/
|
||||||
|
const LOCALES: Record<string, TranslationStrings> = {
|
||||||
|
ar,
|
||||||
|
br,
|
||||||
|
ca,
|
||||||
|
cs,
|
||||||
|
de,
|
||||||
|
es,
|
||||||
|
fr,
|
||||||
|
gr,
|
||||||
|
hu,
|
||||||
|
id,
|
||||||
|
it: itIT,
|
||||||
|
ja,
|
||||||
|
ko,
|
||||||
|
nl,
|
||||||
|
pl,
|
||||||
|
ru,
|
||||||
|
sv,
|
||||||
|
tr,
|
||||||
|
uk,
|
||||||
|
vi,
|
||||||
|
zh,
|
||||||
|
'zh-TW': zhTW,
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLACEHOLDER_RE = /\{[a-zA-Z0-9_]+\}/g;
|
||||||
|
|
||||||
|
describe('i18n placeholder parity', () => {
|
||||||
|
it('every EN placeholder appears in each locale translation of the same key', () => {
|
||||||
|
const violations: string[] = [];
|
||||||
|
|
||||||
|
for (const [enKey, enValue] of Object.entries(en)) {
|
||||||
|
// A few catalog entries are structured data, not strings
|
||||||
|
// (e.g. packing.suggestions.items) — placeholders only apply to strings.
|
||||||
|
if (typeof enValue !== 'string') continue;
|
||||||
|
const placeholders = enValue.match(PLACEHOLDER_RE);
|
||||||
|
if (!placeholders) continue;
|
||||||
|
|
||||||
|
for (const [locale, catalog] of Object.entries(LOCALES)) {
|
||||||
|
const translated = catalog[enKey];
|
||||||
|
if (typeof translated !== 'string') continue;
|
||||||
|
for (const placeholder of new Set(placeholders)) {
|
||||||
|
if (!translated.includes(placeholder)) {
|
||||||
|
violations.push(`${locale} ${enKey}: missing ${placeholder} in ${JSON.stringify(translated)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Foto',
|
'memories.title': 'Foto',
|
||||||
'memories.notConnected': 'Immich non connesso',
|
'memories.notConnected': '{provider_name} non connesso',
|
||||||
'memories.notConnectedHint':
|
'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':
|
'memories.notConnectedMultipleHint':
|
||||||
'Collega uno di questi provider di foto: {provider_names} nelle Impostazioni per poter aggiungere foto a questo viaggio.',
|
'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.noDates': 'Aggiungi le date al tuo viaggio per caricare le foto.',
|
||||||
'memories.noPhotos': 'Nessuna foto trovata',
|
'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.photosFound': 'foto',
|
||||||
'memories.fromOthers': 'da altri',
|
'memories.fromOthers': 'da altri',
|
||||||
'memories.sharePhotos': 'Condividi foto',
|
'memories.sharePhotos': 'Condividi foto',
|
||||||
@@ -30,8 +30,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Testa prima la connessione',
|
'memories.testFirst': 'Testa prima la connessione',
|
||||||
'memories.connected': 'Connesso',
|
'memories.connected': 'Connesso',
|
||||||
'memories.disconnected': 'Non connesso',
|
'memories.disconnected': 'Non connesso',
|
||||||
'memories.connectionSuccess': 'Connesso a Immich',
|
'memories.connectionSuccess': 'Connesso a {provider_name}',
|
||||||
'memories.connectionError': 'Impossibile connettersi a Immich',
|
'memories.connectionError': 'Impossibile connettersi a {provider_name}',
|
||||||
'memories.saved': 'Impostazioni {provider_name} salvate',
|
'memories.saved': 'Impostazioni {provider_name} salvate',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'La connessione a {provider_name} è persa. Riconnetti nelle Impostazioni per visualizzare le foto.',
|
'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.fillRequiredFields': 'Per favore compila tutti i campi obbligatori',
|
||||||
'memories.addPhotos': 'Aggiungi foto',
|
'memories.addPhotos': 'Aggiungi foto',
|
||||||
'memories.linkAlbum': 'Collega album',
|
'memories.linkAlbum': 'Collega album',
|
||||||
'memories.selectAlbum': 'Seleziona album Immich',
|
'memories.selectAlbum': 'Seleziona album {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Seleziona album',
|
'memories.selectAlbumMultiple': 'Seleziona album',
|
||||||
'memories.noAlbums': 'Nessun album trovato',
|
'memories.noAlbums': 'Nessun album trovato',
|
||||||
'memories.syncAlbum': 'Sincronizza album',
|
'memories.syncAlbum': 'Sincronizza album',
|
||||||
'memories.unlinkAlbum': 'Scollega',
|
'memories.unlinkAlbum': 'Scollega',
|
||||||
'memories.photos': 'foto',
|
'memories.photos': 'foto',
|
||||||
'memories.selectPhotos': 'Seleziona foto da Immich',
|
'memories.selectPhotos': 'Seleziona foto da {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Seleziona foto',
|
'memories.selectPhotosMultiple': 'Seleziona foto',
|
||||||
'memories.selectHint': 'Tocca le foto per selezionarle.',
|
'memories.selectHint': 'Tocca le foto per selezionarle.',
|
||||||
'memories.selected': 'selezionate',
|
'memories.selected': 'selezionate',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': "Foto's",
|
'memories.title': "Foto's",
|
||||||
'memories.notConnected': 'Immich niet verbonden',
|
'memories.notConnected': '{provider_name} niet verbonden',
|
||||||
'memories.notConnectedHint': "Verbind je Immich-instantie in Instellingen om je reisfoto's hier te zien.",
|
'memories.notConnectedHint': "Verbind je {provider_name}-instantie in Instellingen om je reisfoto's hier te zien.",
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
"Verbind een van deze fotoproviders: {provider_names} in Instellingen om foto's aan dit reisplan toe te voegen.",
|
"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.noDates': "Voeg data toe aan je reis om foto's te laden.",
|
||||||
'memories.noPhotos': "Geen foto's gevonden",
|
'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.photosFound': "foto's",
|
||||||
'memories.fromOthers': 'van anderen',
|
'memories.fromOthers': 'van anderen',
|
||||||
'memories.sharePhotos': "Foto's delen",
|
'memories.sharePhotos': "Foto's delen",
|
||||||
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Test eerst de verbinding',
|
'memories.testFirst': 'Test eerst de verbinding',
|
||||||
'memories.connected': 'Verbonden',
|
'memories.connected': 'Verbonden',
|
||||||
'memories.disconnected': 'Niet verbonden',
|
'memories.disconnected': 'Niet verbonden',
|
||||||
'memories.connectionSuccess': 'Verbonden met Immich',
|
'memories.connectionSuccess': 'Verbonden met {provider_name}',
|
||||||
'memories.connectionError': 'Kon niet verbinden met Immich',
|
'memories.connectionError': 'Kon niet verbinden met {provider_name}',
|
||||||
'memories.saved': '{provider_name}-instellingen opgeslagen',
|
'memories.saved': '{provider_name}-instellingen opgeslagen',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
"Je {provider_name}-verbinding is verbroken. Maak opnieuw verbinding in Instellingen om foto's te bekijken.",
|
"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.allLocations': 'Alle locaties',
|
||||||
'memories.addPhotos': "Foto's toevoegen",
|
'memories.addPhotos': "Foto's toevoegen",
|
||||||
'memories.linkAlbum': 'Album koppelen',
|
'memories.linkAlbum': 'Album koppelen',
|
||||||
'memories.selectAlbum': 'Immich-album selecteren',
|
'memories.selectAlbum': '{provider_name}-album selecteren',
|
||||||
'memories.selectAlbumMultiple': 'Album selecteren',
|
'memories.selectAlbumMultiple': 'Album selecteren',
|
||||||
'memories.noAlbums': 'Geen albums gevonden',
|
'memories.noAlbums': 'Geen albums gevonden',
|
||||||
'memories.syncAlbum': 'Album synchroniseren',
|
'memories.syncAlbum': 'Album synchroniseren',
|
||||||
'memories.unlinkAlbum': 'Ontkoppelen',
|
'memories.unlinkAlbum': 'Ontkoppelen',
|
||||||
'memories.photos': 'fotos',
|
'memories.photos': 'fotos',
|
||||||
'memories.selectPhotos': "Selecteer foto's uit Immich",
|
'memories.selectPhotos': "Selecteer foto's uit {provider_name}",
|
||||||
'memories.selectPhotosMultiple': "Foto's selecteren",
|
'memories.selectPhotosMultiple': "Foto's selecteren",
|
||||||
'memories.selectHint': "Tik op foto's om ze te selecteren.",
|
'memories.selectHint': "Tik op foto's om ze te selecteren.",
|
||||||
'memories.selected': 'geselecteerd',
|
'memories.selected': 'geselecteerd',
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Zdjęcia',
|
'memories.title': 'Zdjęcia',
|
||||||
'memories.notConnected': 'Immich nie jest połączony',
|
'memories.notConnected': '{provider_name} nie jest połączony',
|
||||||
'memories.notConnectedHint':
|
'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':
|
'memories.notConnectedMultipleHint':
|
||||||
'Połącz jednego z tych dostawców zdjęć: {provider_names} w Ustawieniach, aby móc dodawać zdjęcia do tej podróży.',
|
'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.noDates': 'Dodaj daty do swojej podróży, aby załadować zdjęcia.',
|
||||||
'memories.noPhotos': 'Nie znaleziono zdjęć',
|
'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.photosFound': 'zdjęć',
|
||||||
'memories.fromOthers': 'od innych',
|
'memories.fromOthers': 'od innych',
|
||||||
'memories.sharePhotos': 'Udostępnij zdjęcia',
|
'memories.sharePhotos': 'Udostępnij zdjęcia',
|
||||||
@@ -29,14 +29,14 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testShort': 'Test',
|
'memories.testShort': 'Test',
|
||||||
'memories.connected': 'Połączono',
|
'memories.connected': 'Połączono',
|
||||||
'memories.disconnected': 'Nie połączono',
|
'memories.disconnected': 'Nie połączono',
|
||||||
'memories.connectionSuccess': 'Połączono z Immich',
|
'memories.connectionSuccess': 'Połączono z {provider_name}',
|
||||||
'memories.connectionError': 'Nie udało się połączyć z Immich',
|
'memories.connectionError': 'Nie udało się połączyć z {provider_name}',
|
||||||
'memories.saved': 'Ustawienia {provider_name} zostały zapisane',
|
'memories.saved': 'Ustawienia {provider_name} zostały zapisane',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Połączenie z {provider_name} zostało utracone. Połącz ponownie w Ustawieniach, aby wyświetlać zdjęcia.',
|
'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.saveError': 'Nie można zapisać ustawień {provider_name}',
|
||||||
'memories.addPhotos': 'Dodaj zdjęcia',
|
'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.selectPhotosMultiple': 'Wybierz zdjęcia',
|
||||||
'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.',
|
'memories.selectHint': 'Dotknij zdjęć, aby je zaznaczyć.',
|
||||||
'memories.selected': 'wybranych',
|
'memories.selected': 'wybranych',
|
||||||
@@ -55,7 +55,7 @@ const memories: TranslationStrings = {
|
|||||||
'memories.confirmShareButton': 'Udostępnij zdjęcia',
|
'memories.confirmShareButton': 'Udostępnij zdjęcia',
|
||||||
'memories.testFirst': 'Najpierw przetestuj połączenie',
|
'memories.testFirst': 'Najpierw przetestuj połączenie',
|
||||||
'memories.linkAlbum': 'Połącz album',
|
'memories.linkAlbum': 'Połącz album',
|
||||||
'memories.selectAlbum': 'Wybierz album Immich',
|
'memories.selectAlbum': 'Wybierz album {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Wybierz album',
|
'memories.selectAlbumMultiple': 'Wybierz album',
|
||||||
'memories.noAlbums': 'Nie znaleziono albumów',
|
'memories.noAlbums': 'Nie znaleziono albumów',
|
||||||
'memories.syncAlbum': 'Synchronizuj album',
|
'memories.syncAlbum': 'Synchronizuj album',
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const places: TranslationStrings = {
|
|||||||
'places.importNaverList': 'Lista Naver',
|
'places.importNaverList': 'Lista Naver',
|
||||||
'places.importList': 'Import listy',
|
'places.importList': 'Import listy',
|
||||||
'places.googleListHint': 'Wklej link do listy Google Maps.',
|
'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.googleListError': 'Nie udało się zaimportować listy',
|
||||||
'places.naverListHint': 'Wklej link do udostępnionej listy Naver Maps, aby zaimportować wszystkie miejsca.',
|
'places.naverListHint': 'Wklej link do udostępnionej listy Naver Maps, aby zaimportować wszystkie miejsca.',
|
||||||
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
|
'places.naverListImported': 'Zaimportowano {count} miejsc z "{list}"',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Фото',
|
'memories.title': 'Фото',
|
||||||
'memories.notConnected': 'Immich не подключён',
|
'memories.notConnected': '{provider_name} не подключён',
|
||||||
'memories.notConnectedHint': 'Подключите Immich в настройках, чтобы видеть фотографии из поездок.',
|
'memories.notConnectedHint': 'Подключите {provider_name} в настройках, чтобы видеть фотографии из поездок.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.',
|
'Подключите одного из этих фотопровайдеров: {provider_names} в Настройках, чтобы добавлять фотографии к этому путешествию.',
|
||||||
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
|
'memories.noDates': 'Добавьте даты поездки для загрузки фотографий.',
|
||||||
'memories.noPhotos': 'Фотографии не найдены',
|
'memories.noPhotos': 'Фотографии не найдены',
|
||||||
'memories.noPhotosHint': 'В Immich нет фотографий за период этой поездки.',
|
'memories.noPhotosHint': 'В {provider_name} нет фотографий за период этой поездки.',
|
||||||
'memories.photosFound': 'фото',
|
'memories.photosFound': 'фото',
|
||||||
'memories.fromOthers': 'от других',
|
'memories.fromOthers': 'от других',
|
||||||
'memories.sharePhotos': 'Поделиться фото',
|
'memories.sharePhotos': 'Поделиться фото',
|
||||||
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Сначала проверьте подключение',
|
'memories.testFirst': 'Сначала проверьте подключение',
|
||||||
'memories.connected': 'Подключено',
|
'memories.connected': 'Подключено',
|
||||||
'memories.disconnected': 'Не подключено',
|
'memories.disconnected': 'Не подключено',
|
||||||
'memories.connectionSuccess': 'Подключение к Immich установлено',
|
'memories.connectionSuccess': 'Подключение к {provider_name} установлено',
|
||||||
'memories.connectionError': 'Не удалось подключиться к Immich',
|
'memories.connectionError': 'Не удалось подключиться к {provider_name}',
|
||||||
'memories.saved': 'Настройки {provider_name} сохранены',
|
'memories.saved': 'Настройки {provider_name} сохранены',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.',
|
'Соединение с {provider_name} потеряно. Переподключитесь в Настройках для просмотра фотографий.',
|
||||||
@@ -40,13 +40,13 @@ const memories: TranslationStrings = {
|
|||||||
'memories.allLocations': 'Все места',
|
'memories.allLocations': 'Все места',
|
||||||
'memories.addPhotos': 'Добавить фото',
|
'memories.addPhotos': 'Добавить фото',
|
||||||
'memories.linkAlbum': 'Привязать альбом',
|
'memories.linkAlbum': 'Привязать альбом',
|
||||||
'memories.selectAlbum': 'Выбрать альбом Immich',
|
'memories.selectAlbum': 'Выбрать альбом {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Выбрать альбом',
|
'memories.selectAlbumMultiple': 'Выбрать альбом',
|
||||||
'memories.noAlbums': 'Альбомы не найдены',
|
'memories.noAlbums': 'Альбомы не найдены',
|
||||||
'memories.syncAlbum': 'Синхронизировать',
|
'memories.syncAlbum': 'Синхронизировать',
|
||||||
'memories.unlinkAlbum': 'Отвязать',
|
'memories.unlinkAlbum': 'Отвязать',
|
||||||
'memories.photos': 'фото',
|
'memories.photos': 'фото',
|
||||||
'memories.selectPhotos': 'Выбрать фото из Immich',
|
'memories.selectPhotos': 'Выбрать фото из {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Выбрать фотографии',
|
'memories.selectPhotosMultiple': 'Выбрать фотографии',
|
||||||
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
'memories.selectHint': 'Нажмите на фото, чтобы выбрать их.',
|
||||||
'memories.selected': 'выбрано',
|
'memories.selected': 'выбрано',
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ const admin: TranslationStrings = {
|
|||||||
'admin.github.by': 'ile',
|
'admin.github.by': 'ile',
|
||||||
'admin.github.support': "TREK'i geliştirmeye devam etmeme yardımcı oluyor",
|
'admin.github.support': "TREK'i geliştirmeye devam etmeme yardımcı oluyor",
|
||||||
'admin.update.available': 'Güncelleme mevcut',
|
'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.button': "GitHub'da görüntüle",
|
||||||
'admin.update.install': 'Güncellemeyi Yükle',
|
'admin.update.install': 'Güncellemeyi Yükle',
|
||||||
'admin.update.confirmTitle': 'Güncelleme Yüklensin mi?',
|
'admin.update.confirmTitle': 'Güncelleme Yüklensin mi?',
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const system_notice: TranslationStrings = {
|
|||||||
'system_notice.thank_you_support.cta_kofi': "Ko-fi'de Destek Ol",
|
'system_notice.thank_you_support.cta_kofi': "Ko-fi'de Destek Ol",
|
||||||
'system_notice.pager.prev': 'Önceki bildirim',
|
'system_notice.pager.prev': 'Önceki bildirim',
|
||||||
'system_notice.pager.next': 'Sonraki 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.goto': '{n}. bildirime git',
|
||||||
'system_notice.pager.position': '{total} Bildirimden {current}.',
|
'system_notice.pager.position': '{total} Bildirimden {current}.',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const vacay: TranslationStrings = {
|
|||||||
'vacay.entitlementDays': 'Günler',
|
'vacay.entitlementDays': 'Günler',
|
||||||
'vacay.used': 'Kullanılmış',
|
'vacay.used': 'Kullanılmış',
|
||||||
'vacay.remaining': 'Sol',
|
'vacay.remaining': 'Sol',
|
||||||
'vacay.carriedOver': '{yıl} tarihinden itibaren',
|
'vacay.carriedOver': '{year} tarihinden itibaren',
|
||||||
'vacay.blockWeekends': 'Hafta Sonlarını Engelle',
|
'vacay.blockWeekends': 'Hafta Sonlarını Engelle',
|
||||||
'vacay.blockWeekendsHint': 'Hafta sonu günlerinde tatil girişlerini önleyin',
|
'vacay.blockWeekendsHint': 'Hafta sonu günlerinde tatil girişlerini önleyin',
|
||||||
'vacay.weekendDays': 'Hafta sonu günleri',
|
'vacay.weekendDays': 'Hafta sonu günleri',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': 'Фото',
|
'memories.title': 'Фото',
|
||||||
'memories.notConnected': 'Immich не підключено',
|
'memories.notConnected': '{provider_name} не підключено',
|
||||||
'memories.notConnectedHint': 'Підключіть Immich у налаштуваннях, щоб бачити фотографії з поїздок.',
|
'memories.notConnectedHint': 'Підключіть {provider_name} у налаштуваннях, щоб бачити фотографії з поїздок.',
|
||||||
'memories.notConnectedMultipleHint':
|
'memories.notConnectedMultipleHint':
|
||||||
'Підключіть одного з цих фотопровайдерів: {provider_names} у Налаштуваннях, щоб додавати фотографії до цієї подорожі.',
|
'Підключіть одного з цих фотопровайдерів: {provider_names} у Налаштуваннях, щоб додавати фотографії до цієї подорожі.',
|
||||||
'memories.noDates': 'Додайте дати поїздки, щоб завантажити фотографії.',
|
'memories.noDates': 'Додайте дати поїздки, щоб завантажити фотографії.',
|
||||||
'memories.noPhotos': 'Фотографій не знайдено',
|
'memories.noPhotos': 'Фотографій не знайдено',
|
||||||
'memories.noPhotosHint': 'В Immich немає фотографій за період цієї поїздки.',
|
'memories.noPhotosHint': 'В {provider_name} немає фотографій за період цієї поїздки.',
|
||||||
'memories.photosFound': 'фото',
|
'memories.photosFound': 'фото',
|
||||||
'memories.fromOthers': 'від інших',
|
'memories.fromOthers': 'від інших',
|
||||||
'memories.sharePhotos': 'Поділитися фото',
|
'memories.sharePhotos': 'Поділитися фото',
|
||||||
@@ -29,8 +29,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': 'Спершу перевірте підключення',
|
'memories.testFirst': 'Спершу перевірте підключення',
|
||||||
'memories.connected': 'Підключено',
|
'memories.connected': 'Підключено',
|
||||||
'memories.disconnected': 'Не підключено',
|
'memories.disconnected': 'Не підключено',
|
||||||
'memories.connectionSuccess': 'Підключення до Immich встановлено',
|
'memories.connectionSuccess': 'Підключення до {provider_name} встановлено',
|
||||||
'memories.connectionError': 'Не вдалося підключитися до Immich',
|
'memories.connectionError': 'Не вдалося підключитися до {provider_name}',
|
||||||
'memories.saved': 'Налаштування {provider_name} збережено',
|
'memories.saved': 'Налаштування {provider_name} збережено',
|
||||||
'memories.providerDisconnectedBanner':
|
'memories.providerDisconnectedBanner':
|
||||||
'З’єднання з {provider_name} втрачено. Перепідключіться в Налаштуваннях для перегляду фото.',
|
'З’єднання з {provider_name} втрачено. Перепідключіться в Налаштуваннях для перегляду фото.',
|
||||||
@@ -40,13 +40,13 @@ const memories: TranslationStrings = {
|
|||||||
'memories.allLocations': 'Усі місця',
|
'memories.allLocations': 'Усі місця',
|
||||||
'memories.addPhotos': 'Додати фото',
|
'memories.addPhotos': 'Додати фото',
|
||||||
'memories.linkAlbum': 'Прив’язати альбом',
|
'memories.linkAlbum': 'Прив’язати альбом',
|
||||||
'memories.selectAlbum': 'Вибрати альбом Immich',
|
'memories.selectAlbum': 'Вибрати альбом {provider_name}',
|
||||||
'memories.selectAlbumMultiple': 'Вибрати альбом',
|
'memories.selectAlbumMultiple': 'Вибрати альбом',
|
||||||
'memories.noAlbums': 'Альбомів не знайдено',
|
'memories.noAlbums': 'Альбомів не знайдено',
|
||||||
'memories.syncAlbum': 'Синхронізувати',
|
'memories.syncAlbum': 'Синхронізувати',
|
||||||
'memories.unlinkAlbum': 'Відв’язати',
|
'memories.unlinkAlbum': 'Відв’язати',
|
||||||
'memories.photos': 'фото',
|
'memories.photos': 'фото',
|
||||||
'memories.selectPhotos': 'Вибрати фото з Immich',
|
'memories.selectPhotos': 'Вибрати фото з {provider_name}',
|
||||||
'memories.selectPhotosMultiple': 'Вибрати фотографії',
|
'memories.selectPhotosMultiple': 'Вибрати фотографії',
|
||||||
'memories.selectHint': 'Натисніть на фото, щоб вибрати їх.',
|
'memories.selectHint': 'Натисніть на фото, щоб вибрати їх.',
|
||||||
'memories.selected': 'вибрано',
|
'memories.selected': 'вибрано',
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import type { TranslationStrings } from '../types';
|
|||||||
|
|
||||||
const memories: TranslationStrings = {
|
const memories: TranslationStrings = {
|
||||||
'memories.title': '照片',
|
'memories.title': '照片',
|
||||||
'memories.notConnected': 'Immich 未连接',
|
'memories.notConnected': '{provider_name} 未连接',
|
||||||
'memories.notConnectedHint': '在设置中连接您的 Immich 实例以在此查看旅行照片。',
|
'memories.notConnectedHint': '在设置中连接您的 {provider_name} 实例以在此查看旅行照片。',
|
||||||
'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。',
|
'memories.notConnectedMultipleHint': '请在设置中连接以下任一照片提供商:{provider_names},以便向此行程添加照片。',
|
||||||
'memories.noDates': '为旅行添加日期以加载照片。',
|
'memories.noDates': '为旅行添加日期以加载照片。',
|
||||||
'memories.noPhotos': '未找到照片',
|
'memories.noPhotos': '未找到照片',
|
||||||
'memories.noPhotosHint': 'Immich 中未找到此旅行日期范围内的照片。',
|
'memories.noPhotosHint': '{provider_name} 中未找到此旅行日期范围内的照片。',
|
||||||
'memories.photosFound': '张照片',
|
'memories.photosFound': '张照片',
|
||||||
'memories.fromOthers': '来自他人',
|
'memories.fromOthers': '来自他人',
|
||||||
'memories.sharePhotos': '分享照片',
|
'memories.sharePhotos': '分享照片',
|
||||||
@@ -28,8 +28,8 @@ const memories: TranslationStrings = {
|
|||||||
'memories.testFirst': '请先测试连接',
|
'memories.testFirst': '请先测试连接',
|
||||||
'memories.connected': '已连接',
|
'memories.connected': '已连接',
|
||||||
'memories.disconnected': '未连接',
|
'memories.disconnected': '未连接',
|
||||||
'memories.connectionSuccess': '已连接到 Immich',
|
'memories.connectionSuccess': '已连接到 {provider_name}',
|
||||||
'memories.connectionError': '无法连接到 Immich',
|
'memories.connectionError': '无法连接到 {provider_name}',
|
||||||
'memories.saved': '{provider_name} 设置已保存',
|
'memories.saved': '{provider_name} 设置已保存',
|
||||||
'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。',
|
'memories.providerDisconnectedBanner': '您与 {provider_name} 的连接已断开。请在设置中重新连接以查看照片。',
|
||||||
'memories.saveError': '无法保存 {provider_name} 设置',
|
'memories.saveError': '无法保存 {provider_name} 设置',
|
||||||
@@ -38,13 +38,13 @@ const memories: TranslationStrings = {
|
|||||||
'memories.allLocations': '所有地点',
|
'memories.allLocations': '所有地点',
|
||||||
'memories.addPhotos': '添加照片',
|
'memories.addPhotos': '添加照片',
|
||||||
'memories.linkAlbum': '关联相册',
|
'memories.linkAlbum': '关联相册',
|
||||||
'memories.selectAlbum': '选择 Immich 相册',
|
'memories.selectAlbum': '选择 {provider_name} 相册',
|
||||||
'memories.selectAlbumMultiple': '选择相册',
|
'memories.selectAlbumMultiple': '选择相册',
|
||||||
'memories.noAlbums': '未找到相册',
|
'memories.noAlbums': '未找到相册',
|
||||||
'memories.syncAlbum': '同步相册',
|
'memories.syncAlbum': '同步相册',
|
||||||
'memories.unlinkAlbum': '取消关联',
|
'memories.unlinkAlbum': '取消关联',
|
||||||
'memories.photos': '张照片',
|
'memories.photos': '张照片',
|
||||||
'memories.selectPhotos': '从 Immich 选择照片',
|
'memories.selectPhotos': '从 {provider_name} 选择照片',
|
||||||
'memories.selectPhotosMultiple': '选择照片',
|
'memories.selectPhotosMultiple': '选择照片',
|
||||||
'memories.selectHint': '点击照片以选择。',
|
'memories.selectHint': '点击照片以选择。',
|
||||||
'memories.selected': '已选择',
|
'memories.selected': '已选择',
|
||||||
|
|||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0"?>
|
<?xml version="1.0"?>
|
||||||
<Container version="2">
|
<Container version="2">
|
||||||
<Name>TREK</Name>
|
<Name>TREK</Name>
|
||||||
<Repository>mauriceboe/TREK</Repository>
|
<Repository>mauriceboe/trek</Repository>
|
||||||
<Registry>https://hub.docker.com/r/mauriceboe/TREK</Registry>
|
<Registry>https://hub.docker.com/r/mauriceboe/trek</Registry>
|
||||||
<Branch>
|
<Branch>
|
||||||
<Tag>latest</Tag>
|
<Tag>latest</Tag>
|
||||||
<TagDescription>Latest stable release</TagDescription>
|
<TagDescription>Latest stable release</TagDescription>
|
||||||
|
|||||||
@@ -66,15 +66,15 @@ Three tag strategies are available:
|
|||||||
|
|
||||||
| Tag | Example | Behavior |
|
| Tag | Example | Behavior |
|
||||||
|---|--------------------------|---|
|
|---|--------------------------|---|
|
||||||
| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||||
| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
|
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||||
| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
|
| Full version | `mauriceboe/trek:3.4.0` | Exact release; never changes |
|
||||||
|
|
||||||
The compose file above uses `latest`. To pin, change the `image:` line:
|
The compose file above uses `latest`. To pin, change the `image:` line:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image: mauriceboe/TREK:3 # track major version 3
|
image: mauriceboe/trek:3 # track major version 3
|
||||||
image: mauriceboe/TREK:3.4.0 # pin to exact release
|
image: mauriceboe/trek:3.4.0 # pin to exact release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Start TREK
|
## Start TREK
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ docker run -d \
|
|||||||
-v ./uploads:/app/uploads \
|
-v ./uploads:/app/uploads \
|
||||||
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
||||||
--restart unless-stopped \
|
--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).
|
`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
|
## Image Tags
|
||||||
|
|
||||||
| Tag | Example | Behavior |
|
| Tag | Example | Behavior |
|
||||||
|---|---|---|
|
|---|--------------------------|---|
|
||||||
| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||||
| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
|
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||||
| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
|
| 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
|
## Volume Reference
|
||||||
|
|
||||||
@@ -65,7 +65,7 @@ docker run -d \
|
|||||||
-v trek_uploads:/app/uploads \
|
-v trek_uploads:/app/uploads \
|
||||||
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
-e ENCRYPTION_KEY=<your-32-byte-hex-key> \
|
||||||
--restart unless-stopped \
|
--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.
|
Docker creates `trek_data` and `trek_uploads` automatically on first run. Named volumes are easier to manage with `docker volume` commands and work better in some NAS or container-management environments.
|
||||||
|
|||||||
+14
-2
@@ -5,10 +5,22 @@ Deploy TREK on Kubernetes using the official Helm chart.
|
|||||||
## Add the Chart Repository
|
## Add the Chart Repository
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
helm repo add trek https://mauriceboe.github.io/TREK
|
helm repo add trek https://chart.liketrek.com
|
||||||
helm repo update
|
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
|
## Basic Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -72,7 +84,7 @@ helm install trek trek/trek \
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
image:
|
image:
|
||||||
repository: liketrek/TREK
|
repository: mauriceboe/trek
|
||||||
# tag: latest # defaults to the chart's appVersion
|
# tag: latest # defaults to the chart's appVersion
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ Three tag strategies are available:
|
|||||||
|
|
||||||
| Tag | Example | Behavior |
|
| Tag | Example | Behavior |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||||
| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
|
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||||
| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
|
| 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.
|
Use `latest` or a major-version tag (e.g. `3`) if you want automatic updates on redeploy. Use a full version tag (e.g. `3.4.0`) if you want explicit control over which release runs.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d \
|
|||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
-v ./uploads:/app/uploads \
|
-v ./uploads:/app/uploads \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
mauriceboe/TREK:latest
|
mauriceboe/trek:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
**Flag breakdown:**
|
**Flag breakdown:**
|
||||||
|
|||||||
+27
-5
@@ -10,9 +10,9 @@ Back up your data first. Go to Admin Panel → Backups and create a manual backu
|
|||||||
|
|
||||||
| Tag | Example | Behavior |
|
| Tag | Example | Behavior |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `latest` | `mauriceboe/TREK:latest` | Always the newest release across all major versions |
|
| `latest` | `mauriceboe/trek:latest` | Always the newest release across all major versions |
|
||||||
| Major version | `mauriceboe/TREK:3` | Latest release pinned to that major version |
|
| Major version | `mauriceboe/trek:3` | Latest release pinned to that major version |
|
||||||
| Full version | `mauriceboe/TREK:3.4.0` | Exact release; never changes |
|
| 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.
|
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:
|
If you started TREK with `docker run`, pull the new image and replace the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/TREK
|
docker pull mauriceboe/trek
|
||||||
docker rm -f trek
|
docker rm -f trek
|
||||||
docker run -d --name trek -p 3000:3000 \
|
docker run -d --name trek -p 3000:3000 \
|
||||||
-v ./data:/app/data \
|
-v ./data:/app/data \
|
||||||
-v ./uploads:/app/uploads \
|
-v ./uploads:/app/uploads \
|
||||||
-e ENCRYPTION_KEY=<your-key> \
|
-e ENCRYPTION_KEY=<your-key> \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
mauriceboe/TREK
|
mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Tip:** Not sure which volume paths you used? Check before removing:
|
> **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}}'
|
> docker inspect trek --format '{{json .Mounts}}'
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
## Helm (Kubernetes)
|
||||||
|
|
||||||
|
> **⚠️ Chart repository moved:** The Helm chart is no longer served at `https://mauriceboe.github.io/TREK` (the project moved from a personal repo to the `liketrek` organization). The canonical chart URL is now `https://chart.liketrek.com` — a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK`, so it stays stable even if the repository moves again. If your `trek` repo still points to an old URL, switch it before updating:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> helm repo remove trek
|
||||||
|
> helm repo add trek https://chart.liketrek.com
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> You can check which URL you have configured with `helm repo list`. Existing releases are unaffected — only the repo URL changes.
|
||||||
|
|
||||||
|
To update to the newest chart release:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
helm repo update
|
||||||
|
helm upgrade trek trek/trek
|
||||||
|
```
|
||||||
|
|
||||||
|
Your existing values and PVCs (data, uploads) are preserved. To pin an exact chart version instead, pass `--version <x.y.z>`.
|
||||||
|
|
||||||
|
See [Install-Helm](Install-Helm) for the full installation walkthrough and values reference.
|
||||||
|
|
||||||
## Database Migrations
|
## Database Migrations
|
||||||
|
|
||||||
TREK runs any pending database migrations automatically at startup. No manual migration steps are required after pulling a new image.
|
TREK runs any pending database migrations automatically at startup. No manual migration steps are required after pulling a new image.
|
||||||
|
|||||||
Reference in New Issue
Block a user