From 4d3d16389e1d0164cce4c8bf07e3d312a82d4666 Mon Sep 17 00:00:00 2001 From: SimMesg20 Date: Thu, 23 Jul 2026 21:35:12 +0200 Subject: [PATCH] Feat/journey external picture provider rework (#1632) * feat(journey): add contextual external photo browser * fix(Journey) added missing language keys for new Journey feature * fix(Journey) fixing missing gps requests aswell as added protection against duplicate entries --- .../Journey/JourneyDetailPageEntryEditor.tsx | 178 ++++++++++++++++-- .../JourneyDetailPageProviderPicker.tsx | 80 +++++--- client/src/pages/JourneyDetailPage.test.tsx | 91 +++++++++ client/src/pages/JourneyDetailPage.tsx | 14 +- .../JourneyDetailPage.helpers.test.ts | 27 +++ .../JourneyDetailPage.helpers.ts | 54 ++++++ .../src/services/memories/helpersService.ts | 7 +- server/src/services/memories/immichService.ts | 3 + .../src/services/memories/synologyService.ts | 7 +- .../tests/integration/memories-immich.test.ts | 5 +- .../integration/memories-synology.test.ts | 6 +- shared/src/i18n/ar/journey.ts | 8 + shared/src/i18n/br/journey.ts | 8 + shared/src/i18n/ca/journey.ts | 8 + shared/src/i18n/cs/journey.ts | 8 + shared/src/i18n/de/journey.ts | 8 + shared/src/i18n/en/journey.ts | 8 + shared/src/i18n/es/journey.ts | 8 + shared/src/i18n/fr/journey.ts | 8 + shared/src/i18n/gr/journey.ts | 8 + shared/src/i18n/hu/journey.ts | 8 + shared/src/i18n/id/journey.ts | 8 + shared/src/i18n/it/journey.ts | 8 + shared/src/i18n/ja/journey.ts | 8 + shared/src/i18n/ko/journey.ts | 8 + shared/src/i18n/nl/journey.ts | 8 + shared/src/i18n/pl/journey.ts | 8 + shared/src/i18n/ru/journey.ts | 8 + shared/src/i18n/sv/journey.ts | 8 + shared/src/i18n/tr/journey.ts | 8 + shared/src/i18n/uk/journey.ts | 8 + shared/src/i18n/vi/journey.ts | 8 + shared/src/i18n/zh-TW/journey.ts | 8 + shared/src/i18n/zh/journey.ts | 8 + wiki/Journey-Journal.md | 6 + 35 files changed, 618 insertions(+), 44 deletions(-) create mode 100644 client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts diff --git a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx index d28f5c037..56ac22c1d 100644 --- a/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx +++ b/client/src/components/Journey/JourneyDetailPageEntryEditor.tsx @@ -1,25 +1,31 @@ -import { useState, useRef } from 'react' +import { useEffect, useState, useRef } from 'react' import { X, Plus, Image, Minus, Check, MapPin } from 'lucide-react' import { normalizeImageFiles } from '../../utils/convertHeic' import { type ResilientResult, type UploadProgress } from '../../utils/uploadQueue' import { useTranslation } from '../../i18n' -import { journeyApi, mapsApi } from '../../api/client' +import { journeyApi, mapsApi, addonsApi } from '../../api/client' import { useToast } from '../shared/Toast' import { getApiErrorMessage } from '../../types' -import type { JourneyEntry, JourneyPhoto, GalleryPhoto } from '../../store/journeyStore' +import type { JourneyEntry, JourneyPhoto, GalleryPhoto, JourneyTrip } from '../../store/journeyStore' import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants' -import { photoUrl } from '../../pages/journeyDetail/JourneyDetailPage.helpers' +import { photoUrl, isValidGeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import MarkdownToolbar from './MarkdownToolbar' import { DatePicker } from './JourneyDetailPageDatePicker' +import { ProviderPicker, type ProviderPhotoGroup } from './JourneyDetailPageProviderPicker' -export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClose, onSave, onUploadPhotos, onDone }: { +type PendingProviderGroup = ProviderPhotoGroup & { provider: string } + +export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips, userId = 0, onClose, onSave, onUploadPhotos, onAddProviderPhotos, onDone }: { entry: JourneyEntry journeyId: number tripDates: Set galleryPhotos: GalleryPhoto[] + trips: JourneyTrip[] + userId?: number onClose: () => void - onSave: (data: Record) => Promise + onSave: (data: Record, existingEntryId?: number) => Promise onUploadPhotos: (entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => Promise> + onAddProviderPhotos?: (entryId: number, group: PendingProviderGroup) => Promise onDone: () => void }) { const { t } = useTranslation() @@ -46,8 +52,14 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos const [pendingFiles, setPendingFiles] = useState([]) const [pendingLinkIds, setPendingLinkIds] = useState([]) const [showGalleryPick, setShowGalleryPick] = useState(false) + const [photoTab, setPhotoTab] = useState<'upload' | 'gallery' | 'external'>('upload') + const [availableProviders, setAvailableProviders] = useState<{ id: string; name: string }[]>([]) + const [providersLoading, setProvidersLoading] = useState(false) + const [externalProvider, setExternalProvider] = useState(null) + const [pendingProviderGroups, setPendingProviderGroups] = useState([]) const fileRef = useRef(null) const storyRef = useRef(null) + const persistedEntryIdRef = useRef(entry.id > 0 ? entry.id : null) // Track which fields differ from the entry we started editing so we can // warn before discarding on close/cancel. @@ -66,11 +78,48 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos pros.filter(p => p.trim()).join('\n') !== originalPros || cons.filter(c => c.trim()).join('\n') !== originalCons || pendingFiles.length > 0 || - pendingLinkIds.length > 0 + pendingLinkIds.length > 0 || + pendingProviderGroups.length > 0 ) const availableGalleryPhotos = galleryPhotos.filter(gp => !photos.some(p => p.id === gp.id)) + useEffect(() => { + if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return + let cancelled = false + setProvidersLoading(true) + ;(async () => { + try { + const addonsData = await addonsApi.enabled() + const enabled = (addonsData.addons || []).filter((a: any) => a.type === 'photo_provider' && a.enabled) + const connected: { id: string; name: string }[] = [] + for (const provider of enabled) { + try { + const response = await fetch(`/api/integrations/memories/${provider.id}/status`, { credentials: 'include' }) + if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name }) + } catch {} + } + if (!cancelled) { + setAvailableProviders(connected) + if (connected.length > 0) setExternalProvider(current => current || connected[0].id) + } + } catch {} + if (!cancelled) setProvidersLoading(false) + })() + return () => { cancelled = true } + }, [photoTab, availableProviders.length]) + + const activeExternalProvider = externalProvider || availableProviders[0]?.id || null + const providerExistingAssetIds = new Set() + if (activeExternalProvider) { + photos.forEach(photo => { + if (photo.provider === activeExternalProvider && photo.asset_id) providerExistingAssetIds.add(photo.asset_id) + }) + pendingProviderGroups.forEach(group => { + if (group.provider === activeExternalProvider) group.assetIds.forEach(assetId => providerExistingAssetIds.add(assetId)) + }) + } + const handleClose = () => { if (isDirty && !window.confirm(t('journey.editor.discardChangesConfirm'))) return onClose() @@ -90,8 +139,9 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos mood: mood || null, weather: weather || null, pros_cons: { pros: pros.filter(p => p.trim()), cons: cons.filter(c => c.trim()) }, - type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0)) ? 'entry' : undefined), - }) + type: ((entry.type === 'skeleton' && (story.trim() || pendingFiles.length > 0 || pendingLinkIds.length > 0 || pendingProviderGroups.length > 0)) ? 'entry' : undefined), + }, persistedEntryIdRef.current ?? undefined) + if (entryId > 0) persistedEntryIdRef.current = entryId // upload queued files after entry is created if (pendingFiles.length > 0 && entryId) { const filesToUpload = pendingFiles @@ -116,6 +166,18 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos try { await journeyApi.linkPhoto(entryId, photoId) } catch {} } } + if (pendingProviderGroups.length > 0 && entryId && onAddProviderPhotos) { + const failed: PendingProviderGroup[] = [] + for (const group of pendingProviderGroups) { + try { await onAddProviderPhotos(entryId, group) } catch { failed.push(group) } + } + if (failed.length > 0) { + setPendingProviderGroups(failed) + toast.error(t('journey.editor.externalPhotosPartialFailed', { failed: String(failed.length), total: String(pendingProviderGroups.length) })) + return + } + setPendingProviderGroups([]) + } onDone() } finally { setSaving(false) @@ -131,6 +193,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos setPendingFiles(prev => [...prev, ...normalized]) } + const contextLocation = isValidGeoPoint({ lat: locationLat ?? NaN, lng: locationLng ?? NaN }) + ? { lat: locationLat!, lng: locationLng!, name: locationName || undefined } + : null + return (
{/* The modal itself is constrained to the feed column on desktop so it @@ -163,7 +229,7 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos { (e.target as HTMLInputElement).value = '' }} className="hidden" />
{galleryPhotos.length > 0 && ( )} +
{/* Gallery picker — directly below buttons. Safari collapses @@ -220,6 +297,85 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, onClos
)} + {photoTab === 'external' && ( +
+
+
+

+ {t('journey.editor.externalPhotosFor', { date: new Date(entryDate + 'T00:00:00').toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) })} +

+

+ {contextLocation?.name + ? `${t('journey.editor.externalPhotosNearby') || 'Nearby photos first'} · ${contextLocation.name}` + : (t('journey.editor.externalPhotosNoLocation') || 'All photos from this day')} +

+
+ {pendingProviderGroups.length > 0 && ( + + )} +
+ {providersLoading ? ( +
+ ) : availableProviders.length === 0 ? ( +
{t('journey.editor.externalPhotosUnavailable') || 'No connected photo providers are available.'}
+ ) : ( +
+
+ {availableProviders.map(provider => ( + + ))} +
+ {activeExternalProvider && ( +
+ setExternalProvider(null)} + onAdd={async groups => { + setPendingProviderGroups(previous => { + const next = [...previous] + for (const group of groups) { + const existing = next.find(item => item.provider === activeExternalProvider && item.passphrase === group.passphrase) + if (existing) { + const seen = new Set(existing.assetIds) + group.assetIds.forEach((assetId, index) => { + if (seen.has(assetId)) return + seen.add(assetId) + existing.assetIds.push(assetId) + existing.mediaTypes?.push(group.mediaTypes?.[index] || 'image') + }) + } else { + next.push({ ...group, provider: activeExternalProvider }) + } + } + return next + }) + setExternalProvider(null) + }} + /> +
+ )} +
+ )} +
+ )} {(photos.length > 0 || pendingFiles.length > 0) && (
diff --git a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx index bc318aa0a..968b848ee 100644 --- a/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx +++ b/client/src/components/Journey/JourneyDetailPageProviderPicker.tsx @@ -2,21 +2,27 @@ import { useEffect, useState, useRef, useMemo } from 'react' import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react' import { useTranslation } from '../../i18n' import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore' -import { groupPhotosByDate } from '../../pages/journeyDetail/JourneyDetailPage.helpers' +import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers' import { ScrollTrigger } from './JourneyDetailPageScrollTrigger' import { DatePicker } from './JourneyDetailPageDatePicker' -export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd }: { +export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] } + +export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: { provider: string userId: number entries: JourneyEntry[] trips: JourneyTrip[] existingAssetIds: Set onClose: () => void - onAdd: (groups: Array<{ assetIds: string[]; passphrase?: string; mediaTypes?: string[] }>, entryId: number | null) => Promise + onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise + initialDate?: string + contextLocation?: (GeoPoint & { name?: string }) | null + initialEntryId?: number | null + embedded?: boolean }) { const { t } = useTranslation() - const [filter, setFilter] = useState<'trip' | 'custom' | 'all' | 'album'>('trip') + const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip') const [photos, setPhotos] = useState([]) const [albums, setAlbums] = useState>([]) const [selectedAlbum, setSelectedAlbum] = useState(null) @@ -30,7 +36,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset const [selected, setSelected] = useState>(new Map()) const [customFrom, setCustomFrom] = useState('') const [customTo, setCustomTo] = useState('') - const [targetEntryId, setTargetEntryId] = useState(null) + const [targetEntryId, setTargetEntryId] = useState(initialEntryId ?? null) const [addToOpen, setAddToOpen] = useState(false) const abortRef = useRef(null) const gridRef = useRef(null) @@ -104,7 +110,9 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset // load on mount / filter change useEffect(() => { - if (filter === 'trip' && tripRange.from && tripRange.to) { + if (filter === 'day' && initialDate) { + searchPhotos(initialDate, initialDate) + } else if (filter === 'trip' && tripRange.from && tripRange.to) { searchPhotos(tripRange.from, tripRange.to) } else if (filter === 'all') { searchPhotos('', '') @@ -117,6 +125,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset if (customFrom && customTo) searchPhotos(customFrom, customTo) } + const sortedPhotos = useMemo( + () => sortProviderPhotos(photos, contextLocation), + [photos, contextLocation?.lat, contextLocation?.lng], + ) + const toggleAsset = (id: string) => { setSelected(prev => { const next = new Map(prev) @@ -135,24 +148,38 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset : t('journey.picker.newGallery') return ( -
{ if (e.target === e.currentTarget) e.preventDefault() }}> -
e.stopPropagation()}> +
{ if (!embedded && e.target === e.currentTarget) e.preventDefault() }} + > +
e.stopPropagation()} + > {/* Header */} -
+ {!embedded &&

{provider === 'immich' ? 'Immich' : 'Synology Photos'}

-
+
} {/* Filter bar */}
{/* Tabs */}
{[ + ...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []), { id: 'trip' as const, label: t('journey.picker.tripPeriod') }, { id: 'custom' as const, label: t('journey.picker.dateRange') }, { id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') }, @@ -178,7 +205,16 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
{/* Filter content — always visible row */} -
+ {(!embedded || filter !== 'day') &&
+ {filter === 'day' && initialDate && ( +
+ + + {new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} + + {contextLocation?.name && · near {contextLocation.name}} +
+ )} {filter === 'trip' && (
{tripRange.from && tripRange.to ? ( @@ -231,11 +267,11 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset {albums.length === 0 && !loading && {t('journey.picker.noAlbums')}}
)} -
+
}
{/* Add-to entry selector */} -
+ {!embedded &&
{t('journey.picker.addTo')}
-
+
} {/* Select all bar — sticky above grid */} - {!loading && photos.length > 0 && (() => { - const selectable = photos.filter((a: any) => !existingAssetIds.has(a.id)) + {!loading && sortedPhotos.length > 0 && (() => { + const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id)) const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id)) if (selectable.length === 0) return null return ( @@ -318,7 +354,7 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
- ) : photos.length === 0 ? ( + ) : sortedPhotos.length === 0 ? (

{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')} @@ -326,11 +362,13 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset

) : (
- {groupPhotosByDate(photos).map(group => ( + {groupPhotosByDate(sortedPhotos).map(group => (
-

- {group.label} -

+ {(!embedded || filter !== 'day') && ( +

+ {group.label} +

+ )}
{group.assets.map((asset: any) => { const isSelected = selected.has(asset.id) diff --git a/client/src/pages/JourneyDetailPage.test.tsx b/client/src/pages/JourneyDetailPage.test.tsx index 1457329f6..352cbd50d 100644 --- a/client/src/pages/JourneyDetailPage.test.tsx +++ b/client/src/pages/JourneyDetailPage.test.tsx @@ -3759,4 +3759,95 @@ describe('JourneyDetailPage', () => { }); }); }); + + describe('FE-PAGE-JOURNEYDETAIL-153: Contextual external photos', () => { + it('shows the External photos tab and searches the selected entry day', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + const searches: Array<{ from?: string; to?: string }> = []; + server.use( + http.get('/api/integrations/memories/:provider/status', () => HttpResponse.json({ connected: true })), + http.post('/api/integrations/memories/:provider/search', async ({ request }) => { + searches.push(await request.json() as { from?: string; to?: string }); + return HttpResponse.json({ assets: [{ id: 'context-1', takenAt: '2026-03-15T12:00:00Z', city: 'Rome' }], hasMore: false }); + }), + ); + + await renderAndWait(); + await openEntryEditor(user); + await user.click(screen.getByRole('button', { name: /external photos/i })); + + await waitFor(() => { + expect(screen.getByTestId('journey-external-provider-immich')).toBeInTheDocument(); + }); + await user.click(screen.getByTestId('journey-external-provider-immich')); + await waitFor(() => expect(screen.getByTestId('journey-provider-picker-embedded')).toBeInTheDocument()); + await waitFor(() => expect(screen.getByText(/This day|journey\.picker\.day/)).toBeInTheDocument()); + await waitFor(() => expect(searches.length).toBeGreaterThan(0)); + + expect(searches[0].from).toBe(searches[0].to); + expect(screen.getByText('This day')).toBeInTheDocument(); + expect(document.querySelector('img[src*="/api/integrations/memories/"]')).toBeTruthy(); + + await user.click(screen.getByText('Trip Period')); + await waitFor(() => expect(searches.some(search => search.from === '2026-03-14' && search.to === '2026-03-20')).toBe(true)); + expect(screen.queryByText(/No trips linked/i)).not.toBeInTheDocument(); + }); + + it('does not create a duplicate when provider-photo attachment fails after creating the entry', async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + let createCalls = 0; + let updateCalls = 0; + let providerCalls = 0; + + server.use( + http.get('/api/integrations/memories/:provider/status', () => HttpResponse.json({ connected: true })), + http.post('/api/integrations/memories/:provider/search', () => HttpResponse.json({ + assets: [{ id: 'context-1', takenAt: '2026-03-15T12:00:00Z', city: 'Rome' }], + hasMore: false, + })), + http.post('/api/journeys/1/entries', () => { + createCalls++; + return HttpResponse.json({ + id: 88, journey_id: 1, author_id: 1, type: 'entry', + entry_date: '2026-03-15', title: null, story: null, location_name: null, + location_lat: null, location_lng: null, mood: null, weather: null, + tags: [], pros_cons: null, visibility: 'private', sort_order: 0, + entry_time: null, photos: [], created_at: now, updated_at: now, + }); + }), + http.patch('/api/journeys/entries/88', () => { + updateCalls++; + return HttpResponse.json({ id: 88 }); + }), + http.post('/api/journeys/entries/88/provider-photos', () => { + providerCalls++; + return providerCalls === 1 + ? HttpResponse.json({ error: 'provider unavailable' }, { status: 502 }) + : HttpResponse.json({ added: 1 }); + }), + ); + + await renderAndWait(); + await openEntryEditor(user); + await user.click(screen.getByRole('button', { name: /external photos/i })); + await waitFor(() => expect(screen.getByTestId('journey-external-provider-immich')).toBeInTheDocument()); + await user.click(screen.getByTestId('journey-external-provider-immich')); + + const picker = await waitFor(() => screen.getByTestId('journey-provider-picker-embedded')); + await waitFor(() => expect(picker.querySelector('img[src*="/api/integrations/memories/"]')).toBeTruthy()); + const photo = picker.querySelector('img[src*="/api/integrations/memories/"]')!; + await user.click(photo.closest('[class*="aspect-square"]') as HTMLElement); + await user.click(screen.getByRole('button', { name: /^Add \(1\)/ })); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(providerCalls).toBe(1)); + expect(createCalls).toBe(1); + expect(screen.getByText('New Entry')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(providerCalls).toBe(2)); + expect(createCalls).toBe(1); + expect(updateCalls).toBe(1); + }); + }); }); diff --git a/client/src/pages/JourneyDetailPage.tsx b/client/src/pages/JourneyDetailPage.tsx index 5c18ac55c..a4ab450bc 100644 --- a/client/src/pages/JourneyDetailPage.tsx +++ b/client/src/pages/JourneyDetailPage.tsx @@ -462,21 +462,27 @@ function JourneyDetailPageDesktop() { entry={editingEntry} journeyId={current.id} tripDates={tripDates} + trips={current.trips} galleryPhotos={current.gallery || []} + userId={useAuthStore.getState().user?.id || 0} onClose={() => setEditingEntry(null)} - onSave={async (data) => { - let entryId = editingEntry.id - if (editingEntry.id === 0) { + onSave={async (data, existingEntryId) => { + const currentEntryId = existingEntryId ?? editingEntry.id + let entryId = currentEntryId + if (currentEntryId === 0) { const created = await useJourneyStore.getState().createEntry(current.id, data) entryId = created.id } else { - await updateEntry(editingEntry.id, data) + await updateEntry(currentEntryId, data) } return entryId }} onUploadPhotos={async (entryId, files, cbs) => { return await uploadPhotos(entryId, files, cbs) }} + onAddProviderPhotos={async (entryId, group) => { + await journeyApi.addProviderPhotos(entryId, group.provider, group.assetIds, undefined, group.passphrase, group.mediaTypes) + }} onDone={() => { setEditingEntry(null) loadJourney(Number(id)) diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts new file mode 100644 index 000000000..9e8bcc9bb --- /dev/null +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { distanceBetweenGeoPoints, sortProviderPhotos } from './JourneyDetailPage.helpers'; + +describe('Journey provider photo ranking', () => { + it('places GPS photos nearest the selected Journey location and keeps photos without GPS', () => { + const photos = [ + { id: 'far', lat: 41.95, lng: 12.5 }, + { id: 'unknown', lat: null, lng: null }, + { id: 'near', lat: 41.901, lng: 12.501 }, + ]; + + expect(sortProviderPhotos(photos, { lat: 41.9, lng: 12.5 }).map((photo) => photo.id)).toEqual([ + 'near', + 'far', + 'unknown', + ]); + }); + + it('preserves provider order when the selected entry has no valid location', () => { + const photos = [{ id: 'first' }, { id: 'second' }]; + expect(sortProviderPhotos(photos, { lat: 200, lng: 12 }).map((photo) => photo.id)).toEqual(['first', 'second']); + }); + + it('calculates a zero distance for identical coordinates', () => { + expect(distanceBetweenGeoPoints({ lat: 41.9, lng: 12.5 }, { lat: 41.9, lng: 12.5 })).toBe(0); + }); +}); diff --git a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts index c51a14469..e98d8b687 100644 --- a/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts +++ b/client/src/pages/journeyDetail/JourneyDetailPage.helpers.ts @@ -45,3 +45,57 @@ export function groupPhotosByDate(photos: any[]): { date: string; label: string; assets, })) } + +export interface ProviderPhotoAsset { + id: string + takenAt?: string | null + lat?: number | null + lng?: number | null + [key: string]: unknown +} + +export interface GeoPoint { + lat: number + lng: number +} + +export function isValidGeoPoint(point: Partial | null | undefined): point is GeoPoint { + return !!point && Number.isFinite(point.lat) && Number.isFinite(point.lng) + && point.lat >= -90 && point.lat <= 90 && point.lng >= -180 && point.lng <= 180 +} + +/** Return the great-circle distance in metres between two coordinates. */ +export function distanceBetweenGeoPoints(a: GeoPoint, b: GeoPoint): number { + const earthRadius = 6371000 + const toRadians = (value: number) => value * Math.PI / 180 + const dLat = toRadians(b.lat - a.lat) + const dLng = toRadians(b.lng - a.lng) + const latA = toRadians(a.lat) + const latB = toRadians(b.lat) + const h = Math.sin(dLat / 2) ** 2 + Math.cos(latA) * Math.cos(latB) * Math.sin(dLng / 2) ** 2 + return 2 * earthRadius * Math.asin(Math.sqrt(Math.min(1, h))) +} + +/** + * Keep every asset, but put assets with usable GPS nearest to the selected + * Journey location. The original provider order is the stable fallback. + */ +export function sortProviderPhotos(photos: T[], location?: GeoPoint | null): T[] { + if (!isValidGeoPoint(location)) return photos + + return photos + .map((photo, index) => ({ + photo, + index, + distance: isValidGeoPoint({ lat: photo.lat ?? NaN, lng: photo.lng ?? NaN }) + ? distanceBetweenGeoPoints(location, { lat: photo.lat!, lng: photo.lng! }) + : null, + })) + .sort((a, b) => { + if (a.distance === null && b.distance === null) return a.index - b.index + if (a.distance === null) return 1 + if (b.distance === null) return -1 + return a.distance - b.distance || a.index - b.index + }) + .map(item => item.photo) +} diff --git a/server/src/services/memories/helpersService.ts b/server/src/services/memories/helpersService.ts index 41ae76914..7a8a16a62 100644 --- a/server/src/services/memories/helpersService.ts +++ b/server/src/services/memories/helpersService.ts @@ -67,6 +67,11 @@ export type AlbumsList = { export type Asset = { id: string; takenAt: string; + mediaType?: string; + city?: string | null; + country?: string | null; + lat?: number | null; + lng?: number | null; }; export type AssetsList = { @@ -289,4 +294,4 @@ export async function pipeAsset(url: string, response: Response, headers?: Recor response.status(500).json({ error: 'Failed to fetch asset' }); } } -} \ No newline at end of file +} diff --git a/server/src/services/memories/immichService.ts b/server/src/services/memories/immichService.ts index 5bd1c7159..4932a3288 100644 --- a/server/src/services/memories/immichService.ts +++ b/server/src/services/memories/immichService.ts @@ -198,6 +198,7 @@ export async function searchPhotos( // `isVisible` either, so they still return hidden assets. isVisibleAsset() // below is the only guard on those versions. Do not remove it. visibility: 'timeline', + withExif: true, size, page, }), @@ -217,6 +218,8 @@ export async function searchPhotos( takenAt: a.fileCreatedAt || a.createdAt, city: a.exifInfo?.city || null, country: a.exifInfo?.country || null, + lat: typeof a.exifInfo?.latitude === 'number' ? a.exifInfo.latitude : null, + lng: typeof a.exifInfo?.longitude === 'number' ? a.exifInfo.longitude : null, mediaType: a.type === 'VIDEO' ? 'video' : 'image', })); return { assets, hasMore: items.length >= size }; diff --git a/server/src/services/memories/synologyService.ts b/server/src/services/memories/synologyService.ts index 8e31f4953..5a83201e9 100644 --- a/server/src/services/memories/synologyService.ts +++ b/server/src/services/memories/synologyService.ts @@ -278,8 +278,8 @@ function _normalizeSynologyPhotoInfo(item: SynologyPhotoItem): AssetInfo { aperture: exif.aperture || null, shutter: exif.exposure_time || null, iso: exif.iso || null, - lat: gps.latitude || null, - lng: gps.longitude || null, + lat: typeof gps.latitude === 'number' ? gps.latitude : null, + lng: typeof gps.longitude === 'number' ? gps.longitude : null, orientation: item.additional?.orientation || null, description: item.additional?.description || null, width: item.additional?.resolution?.width || null, @@ -555,7 +555,7 @@ export async function searchSynologyPhotos(userId: number, from?: string, to?: s offset, limit, keyword: '.', - additional: ['thumbnail', 'address'], + additional: ['thumbnail', 'address', 'gps'], }; if (from || to) { @@ -703,4 +703,3 @@ export async function streamSynologyAsset( const url = _buildSynologyEndpoint(synology_credentials.data.synology_url, params.toString()); await pipeAsset(url, response, undefined, undefined, 'public, max-age=86400', { rejectUnauthorized: !synology_credentials.data.synology_skip_ssl }) } - diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index 76ca06df1..b57184941 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -123,7 +123,7 @@ vi.mock('../../src/utils/ssrfGuard', async () => { json: () => Promise.resolve({ assets: { items: [ - { id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France' } }, + { id: 'asset-search-1', fileCreatedAt: '2024-06-01T10:00:00.000Z', exifInfo: { city: 'Paris', country: 'France', latitude: 48.8566, longitude: 2.3522 } }, ], }, }), @@ -347,8 +347,9 @@ describe('Immich browse and search', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); - expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France' }); + expect(res.body.assets[0]).toMatchObject({ id: 'asset-search-1', city: 'Paris', country: 'France', lat: 48.8566, lng: 2.3522 }); expect(typeof res.body.hasMore).toBe('boolean'); + expect(immichState.searchCalls[0]).toMatchObject({ withExif: true }); }); it('IMMICH-043 — POST /search when upstream throws returns 502', async () => { diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index ab9f162b0..cff8e932d 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -977,7 +977,7 @@ describe('Synology searchSynologyPhotos date range', () => { thumbnail: { cache_key: '201_abc' }, address: { city: 'Kyoto', country: 'Japan', state: 'Kyoto' }, exif: {}, - gps: {}, + gps: { latitude: 35.0116, longitude: 135.7681 }, resolution: { width: 4000, height: 3000 }, orientation: 1, description: null, @@ -997,6 +997,7 @@ describe('Synology searchSynologyPhotos date range', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body.assets)).toBe(true); + expect(res.body.assets[0]).toMatchObject({ lat: 35.0116, lng: 135.7681 }); // Verify date parameters were forwarded in the Synology API request body expect(capturedBody).not.toBeNull(); @@ -1006,6 +1007,9 @@ describe('Synology searchSynologyPhotos date range', () => { expect(Number(startTime)).toBeGreaterThan(0); expect(endTime).toBeDefined(); expect(Number(endTime)).toBeGreaterThan(Number(startTime)); + + const additional = JSON.parse(capturedBody!.get('additional') || '[]') as string[]; + expect(additional).toContain('gps'); }); it('SYNO-071 — POST /search without date range omits start_time and end_time', async () => { diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 707fb9acd..32fb80068 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.pdf.theEnd': 'The End', // en-fallback 'journey.pdf.saveAsPdf': 'Save as PDF', // en-fallback 'journey.pdf.pages': 'pages', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index 577e532ef..5121213e8 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index 0e1a0b6cc..6b4ac085b 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': "Vols eliminar {username} d'aquesta travesia?", 'journey.contributors.removed': 'Col·laborador eliminat', 'journey.contributors.removeFailed': "No s'ha pogut eliminar el col·laborador", + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index bba3407ef..5ab03c0e2 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 4c734fa05..d838d12c7 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.picker.selectDate': 'Datum wählen', 'journey.picker.search': 'Suchen', 'journey.detail.journeyTab': 'Journey', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index fb9dd9c9f..a9a153ff1 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -111,6 +111,13 @@ const journey: TranslationStrings = { 'journey.editor.uploadPartialFailed': '{failed} of {total} photos failed — save again to retry', 'journey.editor.fromGallery': 'From Gallery', 'journey.editor.allPhotosAdded': 'All photos already added', + 'journey.editor.externalPhotos': 'External photos', + 'journey.editor.externalPhotosFor': 'Photos for {date}', + 'journey.editor.externalPhotosNearby': 'Nearby photos first', + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', + 'journey.editor.externalPhotosQueued': 'queued', + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', 'journey.editor.writeStory': 'Write your story...', 'journey.editor.prosCons': 'Pros & Cons', 'journey.editor.pros': 'Pros', @@ -217,6 +224,7 @@ const journey: TranslationStrings = { 'journey.pdf.saveAsPdf': 'Save as PDF', 'journey.pdf.pages': 'pages', 'journey.picker.tripPeriod': 'Trip Period', + 'journey.picker.day': 'This day', 'journey.picker.dateRange': 'Date Range', 'journey.picker.allPhotos': 'All Photos', 'journey.picker.albums': 'Albums', diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 43e0f988f..7f329a547 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index bad206184..4112756aa 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -229,5 +229,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index b8b4f656a..66a78114b 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -230,5 +230,13 @@ const journey: TranslationStrings = { 'journey.picker.noAlbums': 'Δεν βρέθηκαν άλμπουμ', 'journey.picker.selectDate': 'Επιλογή ημερομηνίας', 'journey.picker.search': 'Αναζήτηση', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 31118b325..b7c5405a3 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index a29b09af3..adfc52a55 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index 8ebc6e671..67e9c4a74 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 07fd8707d..6bf5e5d97 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -229,5 +229,13 @@ const journey: TranslationStrings = { 'journey.editor.uploadPartialFailed': '{total}枚中{failed}枚の写真がアップロードに失敗しました — もう一度保存して再試行してください', 'journey.photosUploadFailed': '一部の写真をアップロードできませんでした', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index 553db06be..f7abe9850 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -229,5 +229,13 @@ const journey: TranslationStrings = { 'journey.editor.uploadPartialFailed': '{total}개 중 {failed}개의 사진을 업로드하지 못했습니다 — 다시 저장하여 재시도하세요', 'journey.photosUploadFailed': '일부 사진을 업로드하지 못했습니다', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 55183eb94..e038106b2 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -229,5 +229,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index cfdc9b8bc..2d65c50eb 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 687d73a6b..40fdcefec 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index e493ae27e..d625a0b7c 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.picker.noAlbums': 'Inga album hittades', 'journey.picker.selectDate': 'Välj datum', 'journey.picker.search': 'Sök', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 91c814147..751239440 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.editor.uploadFailed': 'Fotoğraf yüklenemedi', 'journey.editor.uploadPartialFailed': '{total} fotoğraftan {failed} tanesi başarısız — tekrar denemek için kaydedin', 'journey.photosUploadFailed': 'Bazı fotoğraflar yüklenemedi', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 49ed9e39c..274b34288 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -229,5 +229,13 @@ const journey: TranslationStrings = { 'journey.editor.uploadPartialFailed': '{failed} з {total} фото не вдалося завантажити — збережіть ще раз, щоб повторити', 'journey.photosUploadFailed': 'Деякі фото не вдалося завантажити', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index baf7d85b3..fbafd0a74 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -228,5 +228,13 @@ const journey: TranslationStrings = { 'journey.picker.noAlbums': 'Không tìm thấy album nào', 'journey.picker.selectDate': 'Chọn ngày', 'journey.picker.search': 'Tìm kiếm', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index 477a4cede..e1adffeb7 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -227,5 +227,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': 'Remove {username} from this journey?', // en-fallback 'journey.contributors.removed': 'Contributor removed', // en-fallback 'journey.contributors.removeFailed': 'Failed to remove contributor', // en-fallback + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index bbb5ba012..ab1b12cfe 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -227,5 +227,13 @@ const journey: TranslationStrings = { 'journey.contributors.removeConfirm': '要将 {username} 从此旅程中移除吗?', 'journey.contributors.removed': '已移除贡献者', 'journey.contributors.removeFailed': '移除贡献者失败', + 'journey.editor.externalPhotos': 'External photos', // en-fallback + 'journey.editor.externalPhotosFor': 'Photos for {date}', // en-fallback + 'journey.editor.externalPhotosNearby': 'Nearby photos first', // en-fallback + 'journey.editor.externalPhotosNoLocation': 'All photos from this day', // en-fallback + 'journey.editor.externalPhotosQueued': 'queued', // en-fallback + 'journey.editor.externalPhotosUnavailable': 'No connected photo providers are available.', // en-fallback + 'journey.editor.externalPhotosPartialFailed': '{failed} photo groups failed — save again to retry', // en-fallback + 'journey.picker.day': 'This day', // en-fallback }; export default journey; diff --git a/wiki/Journey-Journal.md b/wiki/Journey-Journal.md index 4ddfc1691..768273b31 100644 --- a/wiki/Journey-Journal.md +++ b/wiki/Journey-Journal.md @@ -41,6 +41,12 @@ Each entry corresponds to a day in your journey. The entry editor provides: - **Location** — pin the entry to a map location. - **Time** — optionally record a time of day for the entry. +### External photos + +The entry editor includes an **External photos** tab for connected Immich and Synology Photos libraries. It searches the selected calendar day automatically. When the entry has a pinned location, photos with GPS metadata are shown nearest to that location first. Photos without GPS, and photos taken farther away on the same day, remain available below the nearby results; no distance cutoff is applied. + +External selections are queued with the other editor changes and saved only when you click **Save**. If a provider is unavailable or its metadata has no GPS coordinates, Journey falls back to the normal date-based photo list. + ## Mobile timeline On mobile, entries are displayed in a horizontal scrolling timeline of card thumbnails. Tap a card to open the full entry view in a modal sheet. Each card shows the entry's first photo (or a placeholder pin), date, day number, mood icon, and weather icon.