From 81d7022b969c451d626bf489e5cb346e933190ca Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 1 Oct 2026 14:05:47 +0200 Subject: [PATCH] feat(journey): optionally place an entry where its first photo was taken (#1003) --- .../JourneyDetailPageSettingsDialog.test.tsx | 8 +++ .../JourneyDetailPageSettingsDialog.tsx | 31 +++++++++ client/src/store/journeyStore.ts | 2 + server/src/db/migrations.ts | 10 +++ .../nest/journey/journey-domain.service.ts | 48 ++++++++++++- .../journey/journey-photo-capture.module.ts | 4 +- .../journey/journey-photo-capture.service.ts | 30 +++++++- server/src/nest/journey/journey.mcp.ts | 3 +- server/tests/helpers/mcp-test-controllers.ts | 2 +- .../unit/nest/journey-domain.service.test.ts | 51 ++++++++++++++ .../journey-photo-capture.service.test.ts | 68 ++++++++++++++++--- shared/src/i18n/ar/journey.ts | 3 + shared/src/i18n/az/journey.ts | 3 + shared/src/i18n/br/journey.ts | 3 + shared/src/i18n/ca/journey.ts | 3 + shared/src/i18n/cs/journey.ts | 3 + shared/src/i18n/de/journey.ts | 3 + shared/src/i18n/en/journey.ts | 3 + shared/src/i18n/es/journey.ts | 3 + shared/src/i18n/et/journey.ts | 3 + shared/src/i18n/fr/journey.ts | 3 + shared/src/i18n/gr/journey.ts | 3 + shared/src/i18n/hu/journey.ts | 3 + shared/src/i18n/id/journey.ts | 3 + shared/src/i18n/it/journey.ts | 3 + shared/src/i18n/ja/journey.ts | 3 + shared/src/i18n/ko/journey.ts | 3 + shared/src/i18n/nl/journey.ts | 3 + shared/src/i18n/pl/journey.ts | 3 + shared/src/i18n/ru/journey.ts | 3 + shared/src/i18n/sk/journey.ts | 3 + shared/src/i18n/sv/journey.ts | 3 + shared/src/i18n/th/journey.ts | 3 + shared/src/i18n/tr/journey.ts | 3 + shared/src/i18n/uk/journey.ts | 3 + shared/src/i18n/vi/journey.ts | 3 + shared/src/i18n/zh-TW/journey.ts | 3 + shared/src/i18n/zh/journey.ts | 3 + 38 files changed, 322 insertions(+), 16 deletions(-) diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx index b93a146b0..365654835 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.test.tsx @@ -215,6 +215,14 @@ describe('JourneySettingsDialog', () => { await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { status_override: null })) }) + it('FE-JRN-SETTINGS-1003: the photo-location switch saves on the spot', async () => { + const user = userEvent.setup() + const { onRefresh } = mountDialog() + await user.click(screen.getByRole('button', { name: 'Place entries from their photos' })) + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(3, { photo_location: true })) + expect(onRefresh).toHaveBeenCalled() + }) + it('FE-JRN-SETTINGS-011: restores an archived journey', async () => { const user = userEvent.setup() mountDialog(buildJourney({ status: 'archived' })) diff --git a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx index 46b6f4468..c22542ff6 100644 --- a/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx +++ b/client/src/components/Journey/JourneyDetailPageSettingsDialog.tsx @@ -145,6 +145,20 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, } } + // Entries placed from their photos (#1003), saved on the spot like the tracks switch. + const [savingPhotoLocation, setSavingPhotoLocation] = useState(false) + const handlePhotoLocationToggle = async () => { + setSavingPhotoLocation(true) + try { + await updateJourney(journey.id, { photo_location: !journey.photo_location }) + onRefresh() + } catch { + toast.error(t('journey.settings.saveFailed')) + } finally { + setSavingPhotoLocation(false) + } + } + // The state shown for the journey, set by hand (#762). Saved on the spot like // the switches; 'auto' stores null and hands it back to the trip dates. const [savingStatus, setSavingStatus] = useState(false) @@ -301,6 +315,23 @@ export function JourneySettingsDialog({ journey, onClose, onSaved, onOpenInvite, + {/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */} + +
+
+ + {t('journey.settings.photoLocation')} + {t('journey.settings.photoLocationHint')} + + { if (!savingPhotoLocation) handlePhotoLocationToggle() }} + label={t('journey.settings.photoLocation')} + /> +
+
+
+ {/* The three fields a journey may put away (discussion #2299) */}

{t('journey.settings.entryFieldsHint')}

diff --git a/client/src/store/journeyStore.ts b/client/src/store/journeyStore.ts index c3427c30e..1faa1b1c2 100644 --- a/client/src/store/journeyStore.ts +++ b/client/src/store/journeyStore.ts @@ -24,6 +24,8 @@ export interface Journey { * boolean would make `=== true` compile and never hold. */ show_trip_tracks?: number + /** Entries without a place take the position of their first geotagged photo (#1003); 0/1, off by default. */ + photo_location?: number /** * Which of the optional entry fields this journey uses (discussion #2299). * diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 7f0e6de54..31f7dc6a8 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -5440,6 +5440,16 @@ function runMigrations(db: Database.Database): void { if (!has('email')) db.exec('ALTER TABLE places ADD COLUMN email TEXT'); if (!has('opening_hours')) db.exec('ALTER TABLE places ADD COLUMN opening_hours TEXT'); }, + /** + * A journey that puts an entry on the map where its first photo was taken + * (#1003). Off unless the owner turns it on: an entry somebody placed by hand + * is never moved, but an entry left without a place on purpose should not + * grow one either just because a picture was added. + */ + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as { name: string }[]; + if (!cols.some(c => c.name === 'photo_location')) db.exec('ALTER TABLE journeys ADD COLUMN photo_location INTEGER NOT NULL DEFAULT 0'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/journey/journey-domain.service.ts b/server/src/nest/journey/journey-domain.service.ts index 9d786822c..f6c4f8f83 100644 --- a/server/src/nest/journey/journey-domain.service.ts +++ b/server/src/nest/journey/journey-domain.service.ts @@ -353,6 +353,7 @@ export class JourneyDomainService { status: string; status_override: string | null; show_trip_tracks: boolean | number; + photo_location: boolean | number; show_verdict: boolean | number; show_mood: boolean | number; show_weather: boolean | number; @@ -373,13 +374,14 @@ export class JourneyDomainService { 'status', 'status_override', 'show_trip_tracks', + 'photo_location', 'show_verdict', 'show_mood', 'show_weather', ]; // Stored as INTEGER, and better-sqlite3 refuses to bind a JS boolean, so the // flags on this table are coerced rather than passed through. - const BOOLEAN_FIELDS = new Set(['show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather']); + const BOOLEAN_FIELDS = new Set(['show_trip_tracks', 'show_verdict', 'show_mood', 'show_weather', 'photo_location']); const fields: string[] = []; const values: unknown[] = []; for (const [key, val] of Object.entries(data)) { @@ -399,6 +401,50 @@ export class JourneyDomainService { return this.db.prepare('SELECT * FROM journeys WHERE id = ?').get(journeyId) as Journey; } + /** + * Entries without a place that just received a geotagged photo, on journeys + * that asked for it (#1003): each takes the position of its first such photo. + * An entry that already has coordinates is never moved. Answers what changed, + * so the caller can name the places and tell the journeys. + */ + placeEntriesFromPhotos(trekPhotoIds: number[]): { entryId: number; journeyId: number; lat: number; lng: number }[] { + if (!trekPhotoIds.length) return []; + const marks = trekPhotoIds.map(() => '?').join(','); + const rows = this.db.prepare(` + SELECT je.id AS entryId, je.journey_id AS journeyId, tp.lat, tp.lng + FROM trek_photos tp + JOIN journey_photos gp ON gp.photo_id = tp.id + JOIN journey_entry_photos jep ON jep.journey_photo_id = gp.id + JOIN journey_entries je ON je.id = jep.entry_id + JOIN journeys j ON j.id = je.journey_id + WHERE tp.id IN (${marks}) + AND tp.lat IS NOT NULL AND tp.lng IS NOT NULL + AND je.location_lat IS NULL AND je.location_lng IS NULL + AND je.type != 'skeleton' + AND j.photo_location = 1 + ORDER BY je.id, jep.sort_order, jep.journey_photo_id + `).all(...trekPhotoIds) as { entryId: number; journeyId: number; lat: number; lng: number }[]; + const placed: { entryId: number; journeyId: number; lat: number; lng: number }[] = []; + const seen = new Set(); + const now = this.ts(); + const update = this.db.prepare( + 'UPDATE journey_entries SET location_lat = ?, location_lng = ?, country_code = ?, updated_at = ? WHERE id = ? AND location_lat IS NULL AND location_lng IS NULL', + ); + for (const row of rows) { + if (seen.has(row.entryId)) continue; + seen.add(row.entryId); + if (update.run(row.lat, row.lng, this.countryFor(row.lat, row.lng), now, row.entryId).changes > 0) placed.push(row); + } + return placed; + } + + /** The place name for an entry placed from a photo, only while it still has none. */ + nameEntryLocation(entryId: number, name: string): void { + this.db.prepare( + "UPDATE journey_entries SET location_name = ? WHERE id = ? AND (location_name IS NULL OR location_name = '')", + ).run(name, entryId); + } + updateJourneyPreferences(journeyId: number, userId: number, data: { hide_skeletons?: boolean }) { if (!this.canAccessJourney(journeyId, userId)) return null; if (data.hide_skeletons !== undefined) { diff --git a/server/src/nest/journey/journey-photo-capture.module.ts b/server/src/nest/journey/journey-photo-capture.module.ts index ad4670497..bd5518c30 100644 --- a/server/src/nest/journey/journey-photo-capture.module.ts +++ b/server/src/nest/journey/journey-photo-capture.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { MemoriesModule } from '../memories/memories.module'; +import { MapsModule } from '../maps/maps.module'; import { JourneyDomainModule } from './journey-domain.module'; import { JourneyPhotoCaptureService } from './journey-photo-capture.service'; @@ -14,7 +15,8 @@ import { JourneyPhotoCaptureService } from './journey-photo-capture.service'; * because the memories domain knows nothing about journeys. */ @Module({ - imports: [JourneyDomainModule, MemoriesModule], + // MapsModule names an entry placed from its photo (#1003); it is a leaf. + imports: [JourneyDomainModule, MemoriesModule, MapsModule], providers: [JourneyPhotoCaptureService], exports: [JourneyPhotoCaptureService], }) diff --git a/server/src/nest/journey/journey-photo-capture.service.ts b/server/src/nest/journey/journey-photo-capture.service.ts index e711751c3..16db9ef75 100644 --- a/server/src/nest/journey/journey-photo-capture.service.ts +++ b/server/src/nest/journey/journey-photo-capture.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service'; +import { MapsService } from '../maps/maps.service'; import { JourneyDomainService } from './journey-domain.service'; /** The trek_photos ids of freshly added journey photo rows, whatever shape the add answered with. */ @@ -33,6 +34,7 @@ export class JourneyPhotoCaptureService { constructor( private readonly backfill: PhotoCaptureBackfillService, private readonly journey: JourneyDomainService, + private readonly maps: MapsService, ) {} /** Provider photos just added to a journey, to its gallery or to one of its entries. Detached. */ @@ -61,7 +63,31 @@ export class JourneyPhotoCaptureService { scheduleUpload(photos: readonly unknown[], userId: number): void { const ids = trekPhotoIdsOf(photos); if (!ids.length) return; - this.backfill.schedule(ids, userId); + void this.backfill.run(ids, userId) + .then(() => this.placeEntries(ids)) + .catch(err => console.error('[Journey] capture for uploads failed:', err instanceof Error ? err.message : err)); + } + + /** + * Entries waiting for a place get the one their photo was taken at (#1003), + * then a name for it, then everyone on the journey hears about it. The name is + * best effort: a geocoder that does not answer leaves a pin without a label, + * which the owner can still name by hand. + */ + async placeEntries(trekPhotoIds: number[]): Promise { + const placed = this.journey.placeEntriesFromPhotos(trekPhotoIds); + if (!placed.length) return 0; + await Promise.all(placed.map(async (p) => { + try { + const where = await this.maps.reverseGeocode(String(p.lat), String(p.lng), undefined, { timeoutMs: 8000, locality: true }); + const name = where.name || where.address; + if (name) this.journey.nameEntryLocation(p.entryId, name); + } catch { /* the pin stands without a name */ } + })); + for (const journeyId of new Set(placed.map(p => p.journeyId))) { + this.journey.broadcastJourneyEvent(journeyId, 'journey:photos:updated', {}); + } + return placed.length; } /** @@ -74,6 +100,8 @@ export class JourneyPhotoCaptureService { let changed = false; try { changed = await this.backfill.run(trekPhotoIds, userId); + // Its own broadcast when it places something; the one below is about order. + await this.placeEntries(trekPhotoIds); if (changed && journeyId != null) { this.journey.broadcastJourneyEvent(journeyId, 'journey:photos:updated', {}); } diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 7ad650fc7..f82fa838a 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -251,6 +251,7 @@ export class JourneyMcp { show_verdict: z.boolean().optional().describe('Whether entries in this journey offer a pros/cons list'), show_mood: z.boolean().optional().describe('Whether entries in this journey offer a mood'), show_weather: z.boolean().optional().describe('Whether entries in this journey offer a weather note'), + photo_location: z.boolean().optional().describe('Whether an entry without a place takes the position of the first geotagged photo added to it'), }, annotations: TOOL_ANNOTATIONS_WRITE, when: journeyAddonOn, @@ -259,7 +260,7 @@ export class JourneyMcp { updateJourney( { journeyId, ...data }: { journeyId: number; title?: string; subtitle?: string; status?: string; status_override?: string | null; - show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean; + show_verdict?: boolean; show_mood?: boolean; show_weather?: boolean; photo_location?: boolean; }, ctx: McpContext, ) { diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index a8da2d421..e5295f9b5 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -249,7 +249,7 @@ export function createMcpTestRegistry(): McpRegistry { new CollectionsMcp(new CollectionsService(dbService, permissionsService, realtimeService, notificationsStub(), generalStorage), dbService, authService, addonsService), new TransitMcp(new TransitService(new GoogleTransitProvider(dbService, noGoogleQuota)), daysService, reservationsService, dbService, authService, guards), new AtlasMcp(new AtlasService(dbService), addonsService, authService), - new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain)), + new JourneyMcp(journeyDomain, new JourneyShareService(dbService, journeyDomain, new SettingsService(dbService)), addonsService, authService, new JourneyPhotoCaptureService(captureBackfill, journeyDomain, { reverseGeocode: async () => ({ name: null, address: null }) } as never)), new MemoriesMcp(immichService, synologyService, dbService, addonsService), new NotificationsMcp(makeNotificationsService(dbService, realtimeService), authService), new AirtrailMcp(new AirtrailService(dbService, new AuditService(dbService), new AirtrailClient()), addonsService), diff --git a/server/tests/unit/nest/journey-domain.service.test.ts b/server/tests/unit/nest/journey-domain.service.test.ts index a2fa4b501..440b1a014 100644 --- a/server/tests/unit/nest/journey-domain.service.test.ts +++ b/server/tests/unit/nest/journey-domain.service.test.ts @@ -291,6 +291,57 @@ describe('getJourneyFull', () => { }); }); +describe('placeEntriesFromPhotos (#1003)', () => { + /** A geotagged (or not) photo on an entry, wired the way the upload wires it. */ + function photoOnEntry(journeyId: number, entryId: number, ownerId: number, coords: [number, number] | null, sort = 0) { + const tp = testDb.prepare("INSERT INTO trek_photos (provider, owner_id, file_path, lat, lng) VALUES ('local', ?, 'journey/x.jpg', ?, ?)") + .run(ownerId, coords?.[0] ?? null, coords?.[1] ?? null).lastInsertRowid as number; + const gp = testDb.prepare('INSERT INTO journey_photos (journey_id, photo_id, shared, sort_order, created_at) VALUES (?, ?, 1, 0, ?)') + .run(journeyId, tp, Date.now()).lastInsertRowid as number; + testDb.prepare('INSERT INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at) VALUES (?, ?, ?, ?)').run(entryId, gp, sort, Date.now()); + return tp; + } + + it('JOURNEY-SVC-1003-1: with the setting on, a placeless entry takes the position of its first geotagged photo, once', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + testDb.prepare('UPDATE journeys SET photo_location = 1 WHERE id = ?').run(journey.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + const blank = photoOnEntry(journey.id, entry.id, user.id, null, 0); + const first = photoOnEntry(journey.id, entry.id, user.id, [48.137, 11.575], 1); + const second = photoOnEntry(journey.id, entry.id, user.id, [40.4, -3.7], 2); + + expect(svc.placeEntriesFromPhotos([blank, first, second])).toEqual([{ entryId: entry.id, journeyId: journey.id, lat: 48.137, lng: 11.575 }]); + const row = testDb.prepare('SELECT location_lat, location_lng, country_code FROM journey_entries WHERE id = ?').get(entry.id) as any; + expect(row).toMatchObject({ location_lat: 48.137, location_lng: 11.575 }); + + // Placed now: a later photo does not move it, and a name only lands while there is none. + expect(svc.placeEntriesFromPhotos([second])).toEqual([]); + svc.nameEntryLocation(entry.id, 'Marienplatz'); + svc.nameEntryLocation(entry.id, 'Somewhere else'); + expect((testDb.prepare('SELECT location_name FROM journey_entries WHERE id = ?').get(entry.id) as any).location_name).toBe('Marienplatz'); + }); + + it('JOURNEY-SVC-1003-2: off by default, and it leaves entries with a place alone', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + const entry = createJourneyEntry(testDb, journey.id, user.id); + const photo = photoOnEntry(journey.id, entry.id, user.id, [1, 2]); + expect(svc.placeEntriesFromPhotos([photo])).toEqual([]); + + testDb.prepare('UPDATE journeys SET photo_location = 1 WHERE id = ?').run(journey.id); + testDb.prepare('UPDATE journey_entries SET location_lat = 5, location_lng = 6 WHERE id = ?').run(entry.id); + expect(svc.placeEntriesFromPhotos([photo])).toEqual([]); + expect(svc.placeEntriesFromPhotos([])).toEqual([]); + }); + + it('JOURNEY-SVC-1003-3: the owner turns it on through updateJourney', () => { + const { user } = createUser(testDb); + const journey = createJourney(testDb, user.id); + expect((svc.updateJourney(journey.id, user.id, { photo_location: true }) as any).photo_location).toBe(1); + }); +}); + describe('updateJourney', () => { it('JOURNEY-SVC-018: owner can update title and subtitle', () => { const { user } = createUser(testDb); diff --git a/server/tests/unit/nest/journey-photo-capture.service.test.ts b/server/tests/unit/nest/journey-photo-capture.service.test.ts index e6f8e6b1c..cacd3385b 100644 --- a/server/tests/unit/nest/journey-photo-capture.service.test.ts +++ b/server/tests/unit/nest/journey-photo-capture.service.test.ts @@ -10,6 +10,7 @@ import type { JourneyDomainService } from '../../../src/nest/journey/journey-dom import type { PhotoResolverService } from '../../../src/nest/memories/photo-resolver.service'; import type { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository'; import type { StorageService } from '../../../src/nest/storage/storage.service'; +import type { MapsService } from '../../../src/nest/maps/maps.service'; function build(changed: boolean | Error = true) { const run = vi.fn(async () => { @@ -19,11 +20,15 @@ function build(changed: boolean | Error = true) { const schedule = vi.fn(); const broadcastJourneyEvent = vi.fn(); const journeyIdOfEntry = vi.fn((entryId: number) => (entryId === 4 ? 9 : null)); + const placeEntriesFromPhotos = vi.fn((): { entryId: number; journeyId: number; lat: number; lng: number }[] => []); + const nameEntryLocation = vi.fn(); + const reverseGeocode = vi.fn(async () => ({ name: 'Marienplatz', address: 'Munich' })); const svc = new JourneyPhotoCaptureService( { run, schedule } as unknown as PhotoCaptureBackfillService, - { broadcastJourneyEvent, journeyIdOfEntry } as unknown as JourneyDomainService, + { broadcastJourneyEvent, journeyIdOfEntry, placeEntriesFromPhotos, nameEntryLocation } as unknown as JourneyDomainService, + { reverseGeocode } as unknown as MapsService, ); - return { svc, run, schedule, broadcastJourneyEvent, journeyIdOfEntry }; + return { svc, run, schedule, broadcastJourneyEvent, journeyIdOfEntry, placeEntriesFromPhotos, nameEntryLocation, reverseGeocode }; } beforeEach(() => vi.restoreAllMocks()); @@ -101,21 +106,61 @@ describe('JourneyPhotoCaptureService', () => { expect(error).toHaveBeenLastCalledWith('[Journey] capture refresh failed for journey null:', 'gone'); }); - it('JPCAP-008: an upload gets the plain backfill and no refresh, since one request is one file', async () => { - const { svc, run, schedule, broadcastJourneyEvent, journeyIdOfEntry } = build(true); + it('JPCAP-008: an upload gets the backfill and no order refresh, since one request is one file', async () => { + const { svc, run, broadcastJourneyEvent, journeyIdOfEntry, placeEntriesFromPhotos } = build(true); svc.scheduleUpload([{ id: 1, photo_id: 11 }, null, { id: 2 }], 3); - await new Promise(resolve => setTimeout(resolve, 5)); + await vi.waitFor(() => expect(placeEntriesFromPhotos).toHaveBeenCalledWith([11])); - expect(schedule).toHaveBeenCalledWith([11], 3); - expect(run).not.toHaveBeenCalled(); + expect(run).toHaveBeenCalledWith([11], 3); expect(journeyIdOfEntry).not.toHaveBeenCalled(); expect(broadcastJourneyEvent).not.toHaveBeenCalled(); - // Nothing with a trek photo id: nothing to schedule. - schedule.mockClear(); + // Nothing with a trek photo id: nothing to run. + run.mockClear(); svc.scheduleUpload([{ id: 3 }], 3); - expect(schedule).not.toHaveBeenCalled(); + expect(run).not.toHaveBeenCalled(); + }); + + it('JPCAP-1003: an entry placed from its photo gets a name and the journey hears about it', async () => { + const { svc, placeEntriesFromPhotos, nameEntryLocation, reverseGeocode, broadcastJourneyEvent } = build(false); + placeEntriesFromPhotos.mockReturnValueOnce([ + { entryId: 5, journeyId: 9, lat: 48.137, lng: 11.575 }, + { entryId: 6, journeyId: 9, lat: 1, lng: 2 }, + ]); + reverseGeocode.mockResolvedValueOnce({ name: 'Marienplatz', address: 'Munich' }).mockRejectedValueOnce(new Error('timeout')); + + await expect(svc.placeEntries([11, 12])).resolves.toBe(2); + expect(reverseGeocode).toHaveBeenCalledWith('48.137', '11.575', undefined, { timeoutMs: 8000, locality: true }); + // The second geocode failed: its pin stands without a name. + expect(nameEntryLocation).toHaveBeenCalledTimes(1); + expect(nameEntryLocation).toHaveBeenCalledWith(5, 'Marienplatz'); + expect(broadcastJourneyEvent).toHaveBeenCalledTimes(1); + expect(broadcastJourneyEvent).toHaveBeenCalledWith(9, 'journey:photos:updated', {}); + + // A provider add runs the same placing after its backfill. + placeEntriesFromPhotos.mockClear(); + await svc.fill(9, [13], 3); + expect(placeEntriesFromPhotos).toHaveBeenCalledWith([13]); + }); + + it('JPCAP-1003b: nothing placed, nothing asked and nothing said; an address stands in for a missing name', async () => { + const { svc, reverseGeocode, broadcastJourneyEvent, placeEntriesFromPhotos, nameEntryLocation } = build(false); + await expect(svc.placeEntries([11])).resolves.toBe(0); + expect(reverseGeocode).not.toHaveBeenCalled(); + expect(broadcastJourneyEvent).not.toHaveBeenCalled(); + + placeEntriesFromPhotos.mockReturnValueOnce([{ entryId: 7, journeyId: 9, lat: 1, lng: 2 }]); + reverseGeocode.mockResolvedValueOnce({ name: null, address: 'Somewhere Road' } as never); + await svc.placeEntries([11]); + expect(nameEntryLocation).toHaveBeenCalledWith(7, 'Somewhere Road'); + }); + + it('JPCAP-1003c: an upload whose backfill throws is logged', async () => { + const { svc } = build(new Error('disk')); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + svc.scheduleUpload([{ photo_id: 11 }], 3); + await vi.waitFor(() => expect(error).toHaveBeenCalledWith('[Journey] capture for uploads failed:', 'disk')); }); it('JPCAP-009: provider adds from every surface share the user\'s four lookup slots, uploads read their own files, and only the provider adds refresh', async () => { @@ -143,7 +188,8 @@ describe('JourneyPhotoCaptureService', () => { const broadcastJourneyEvent = vi.fn(); const svc = new JourneyPhotoCaptureService( backfill, - { broadcastJourneyEvent, journeyIdOfEntry: () => 9 } as unknown as JourneyDomainService, + { broadcastJourneyEvent, journeyIdOfEntry: () => 9, placeEntriesFromPhotos: () => [] } as unknown as JourneyDomainService, + { reverseGeocode: vi.fn() } as unknown as MapsService, ); const rows = (from: number) => Array.from({ length: 5 }, (_, i) => ({ photo_id: from + i })); diff --git a/shared/src/i18n/ar/journey.ts b/shared/src/i18n/ar/journey.ts index 50da2d23f..6b047c59c 100644 --- a/shared/src/i18n/ar/journey.ts +++ b/shared/src/i18n/ar/journey.ts @@ -228,6 +228,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'تلقائي', 'journey.settings.statusAutoHint': 'يتبع تواريخ الرحلات المرتبطة. بدون رحلة تبقى الرحلة مسودة.', 'journey.settings.statusManualHint': 'مضبوطة يدويًا. لن تغيرها تواريخ الرحلة حتى تعود إلى الوضع التلقائي.', + 'journey.settings.photosSection': 'الصور', + 'journey.settings.photoLocation': 'تحديد موقع الإدخالات من صورها', + 'journey.settings.photoLocationHint': 'يأخذ الإدخال الذي بلا مكان موقع أول صورة له تحمل GPS. لا تُنقل الأماكن التي حددتها بنفسك أبدًا.', 'journey.settings.delete': 'Delete', // en-fallback 'journey.settings.deleteJourney': 'Delete Journey', // en-fallback 'journey.settings.deleteMessage': 'Delete "{title}"? All entries and photos will be lost.', // en-fallback diff --git a/shared/src/i18n/az/journey.ts b/shared/src/i18n/az/journey.ts index d2e1fdd8c..cba8f1480 100644 --- a/shared/src/i18n/az/journey.ts +++ b/shared/src/i18n/az/journey.ts @@ -253,6 +253,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Avtomatik', 'journey.settings.statusAutoHint': 'Bağlı səfərlərin tarixlərinə uyğunlaşır. Səfər olmadan jurnal qaralama qalır.', 'journey.settings.statusManualHint': 'Əllə təyin olunub. Avtomatikə qaytarana qədər səfər tarixləri onu dəyişmir.', + 'journey.settings.photosSection': 'Fotolar', + 'journey.settings.photoLocation': 'Qeydləri fotolarına görə yerləşdir', + 'journey.settings.photoLocationHint': 'Yeri olmayan qeyd GPS-li ilk fotonun çəkildiyi yeri götürür. Özünüzün təyin etdiyi yerlər heç vaxt dəyişmir.', 'journey.settings.endJourney': 'Səyahət gündəliyini arxivləşdir', 'journey.settings.reopenJourney': 'Səyahət gündəliyini bərpa et', 'journey.settings.archived': 'Səyahət gündəliyi arxivləşdirildi', diff --git a/shared/src/i18n/br/journey.ts b/shared/src/i18n/br/journey.ts index d5a89197b..ddd3d317a 100644 --- a/shared/src/i18n/br/journey.ts +++ b/shared/src/i18n/br/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automático', 'journey.settings.statusAutoHint': 'Segue as datas das viagens vinculadas. Sem viagem, a jornada continua como rascunho.', 'journey.settings.statusManualHint': 'Definido manualmente. As datas da viagem não o alteram até você voltar para automático.', + 'journey.settings.photosSection': 'Fotos', + 'journey.settings.photoLocation': 'Localizar entradas pelas fotos', + 'journey.settings.photoLocationHint': 'Uma entrada sem lugar recebe o ponto onde foi tirada sua primeira foto com GPS. Lugares definidos por você nunca são movidos.', 'journey.settings.endJourney': 'Arquivar Jornada', 'journey.settings.reopenJourney': 'Restaurar Jornada', 'journey.settings.archived': 'Jornada arquivada', diff --git a/shared/src/i18n/ca/journey.ts b/shared/src/i18n/ca/journey.ts index bb80e9a38..1fa8a2902 100644 --- a/shared/src/i18n/ca/journey.ts +++ b/shared/src/i18n/ca/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automàtic', 'journey.settings.statusAutoHint': 'Segueix les dates dels viatges vinculats. Sense viatge, el diari es queda com a esborrany.', 'journey.settings.statusManualHint': 'Definit a mà. Les dates del viatge ja no el canvien fins que tornis a automàtic.', + 'journey.settings.photosSection': 'Fotos', + 'journey.settings.photoLocation': 'Situa les entrades segons les seves fotos', + 'journey.settings.photoLocationHint': 'Una entrada sense lloc agafa el punt on es va fer la primera foto amb GPS. Els llocs que poses tu no es mouen mai.', 'journey.settings.endJourney': 'Arxiva el viatge', 'journey.settings.reopenJourney': 'Restaura el viatge', 'journey.settings.archived': 'Viatge arxivat', diff --git a/shared/src/i18n/cs/journey.ts b/shared/src/i18n/cs/journey.ts index 727ebe2a6..e6f61ccaa 100644 --- a/shared/src/i18n/cs/journey.ts +++ b/shared/src/i18n/cs/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automaticky', 'journey.settings.statusAutoHint': 'Řídí se daty propojených výletů. Bez výletu zůstane deník konceptem.', 'journey.settings.statusManualHint': 'Nastaveno ručně. Data výletu ho nezmění, dokud nepřepnete zpět na automaticky.', + 'journey.settings.photosSection': 'Fotky', + 'journey.settings.photoLocation': 'Umístit záznamy podle jejich fotek', + 'journey.settings.photoLocationHint': 'Záznam bez místa převezme místo, kde vznikla jeho první fotka s GPS. Místa nastavená ručně se nikdy nepřesunou.', 'journey.settings.endJourney': 'Archivovat cestu', 'journey.settings.reopenJourney': 'Obnovit cestu', 'journey.settings.archived': 'Cesta archivována', diff --git a/shared/src/i18n/de/journey.ts b/shared/src/i18n/de/journey.ts index 0c79b6f45..b2bb75af5 100644 --- a/shared/src/i18n/de/journey.ts +++ b/shared/src/i18n/de/journey.ts @@ -206,6 +206,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatisch', 'journey.settings.statusAutoHint': 'Richtet sich nach den Daten der verknüpften Trips. Ohne Trip bleibt die Journey ein Entwurf.', 'journey.settings.statusManualHint': 'Von Hand gesetzt. Die Trip-Daten ändern ihn nicht mehr, bis du wieder auf automatisch stellst.', + 'journey.settings.photosSection': 'Fotos', + 'journey.settings.photoLocation': 'Einträge über ihre Fotos verorten', + 'journey.settings.photoLocationHint': 'Ein Eintrag ohne Ort übernimmt die Stelle, an der sein erstes Foto mit GPS aufgenommen wurde. Selbst gesetzte Orte werden nie verschoben.', 'journey.settings.endJourney': 'Reise archivieren', 'journey.settings.reopenJourney': 'Reise wiederherstellen', 'journey.settings.archived': 'Reise archiviert', diff --git a/shared/src/i18n/en/journey.ts b/shared/src/i18n/en/journey.ts index b2493989e..3dc6ff461 100644 --- a/shared/src/i18n/en/journey.ts +++ b/shared/src/i18n/en/journey.ts @@ -214,6 +214,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatic', 'journey.settings.statusAutoHint': 'Follows the dates of the linked trips. Without a trip the journey stays a draft.', 'journey.settings.statusManualHint': 'Set by hand. The trip dates no longer change it until you switch back to automatic.', + 'journey.settings.photosSection': 'Photos', + 'journey.settings.photoLocation': 'Place entries from their photos', + 'journey.settings.photoLocationHint': 'An entry without a place takes the spot where its first photo with GPS was taken. Places you set yourself are never moved.', 'journey.settings.endJourney': 'Archive Journey', 'journey.settings.reopenJourney': 'Restore Journey', 'journey.settings.archived': 'Journey archived', diff --git a/shared/src/i18n/es/journey.ts b/shared/src/i18n/es/journey.ts index 2b4477b2f..7de3c6c19 100644 --- a/shared/src/i18n/es/journey.ts +++ b/shared/src/i18n/es/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automático', 'journey.settings.statusAutoHint': 'Sigue las fechas de los viajes vinculados. Sin viaje, la travesía se queda como borrador.', 'journey.settings.statusManualHint': 'Definido a mano. Las fechas del viaje ya no lo cambian hasta que vuelvas a automático.', + 'journey.settings.photosSection': 'Fotos', + 'journey.settings.photoLocation': 'Ubicar entradas según sus fotos', + 'journey.settings.photoLocationHint': 'Una entrada sin lugar toma el punto donde se hizo su primera foto con GPS. Los lugares que pongas tú nunca se mueven.', 'journey.settings.endJourney': 'Archivar viaje', 'journey.settings.reopenJourney': 'Restaurar viaje', 'journey.settings.archived': 'Viaje archivado', diff --git a/shared/src/i18n/et/journey.ts b/shared/src/i18n/et/journey.ts index 0ba10c60e..42b10b1a3 100644 --- a/shared/src/i18n/et/journey.ts +++ b/shared/src/i18n/et/journey.ts @@ -216,6 +216,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automaatne', 'journey.settings.statusAutoHint': 'Järgib seotud reiside kuupäevi. Ilma reisita jääb teekond mustandiks.', 'journey.settings.statusManualHint': 'Määratud käsitsi. Reisi kuupäevad ei muuda seda enam, kuni lülitad tagasi automaatsele.', + 'journey.settings.photosSection': 'Fotod', + 'journey.settings.photoLocation': 'Paiguta sissekanded fotode järgi', + 'journey.settings.photoLocationHint': 'Kohata sissekanne saab koha, kus tehti selle esimene GPS-iga foto. Ise määratud kohti ei liigutata kunagi.', 'journey.settings.endJourney': 'Arhiveeri reisilugu', 'journey.settings.reopenJourney': 'Taasta reisilugu', 'journey.settings.archived': 'Reisilugu arhiveeritud', diff --git a/shared/src/i18n/fr/journey.ts b/shared/src/i18n/fr/journey.ts index 9bc5c6b9f..d805ec47a 100644 --- a/shared/src/i18n/fr/journey.ts +++ b/shared/src/i18n/fr/journey.ts @@ -203,6 +203,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatique', 'journey.settings.statusAutoHint': 'Suit les dates des voyages liés. Sans voyage, le carnet reste un brouillon.', 'journey.settings.statusManualHint': 'Défini à la main. Les dates du voyage ne le changent plus tant que vous ne repassez pas en automatique.', + 'journey.settings.photosSection': 'Photos', + 'journey.settings.photoLocation': "Placer les entrées d'après leurs photos", + 'journey.settings.photoLocationHint': "Une entrée sans lieu prend l'endroit où sa première photo avec GPS a été prise. Les lieux que vous avez définis ne sont jamais déplacés.", 'journey.settings.endJourney': 'Archiver le journal', 'journey.settings.reopenJourney': 'Restaurer le journal', 'journey.settings.archived': 'Journal archivé', diff --git a/shared/src/i18n/gr/journey.ts b/shared/src/i18n/gr/journey.ts index 2f1935c56..37efc8b8e 100644 --- a/shared/src/i18n/gr/journey.ts +++ b/shared/src/i18n/gr/journey.ts @@ -209,6 +209,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Αυτόματα', 'journey.settings.statusAutoHint': 'Ακολουθεί τις ημερομηνίες των συνδεδεμένων ταξιδιών. Χωρίς ταξίδι, το ημερολόγιο μένει πρόχειρο.', 'journey.settings.statusManualHint': 'Ορισμένη χειροκίνητα. Οι ημερομηνίες του ταξιδιού δεν την αλλάζουν μέχρι να επιστρέψετε στο αυτόματο.', + 'journey.settings.photosSection': 'Φωτογραφίες', + 'journey.settings.photoLocation': 'Τοποθέτηση καταχωρίσεων από τις φωτογραφίες τους', + 'journey.settings.photoLocationHint': 'Μια καταχώριση χωρίς τοποθεσία παίρνει το σημείο όπου τραβήχτηκε η πρώτη της φωτογραφία με GPS. Οι τοποθεσίες που ορίζετε εσείς δεν μετακινούνται ποτέ.', 'journey.settings.endJourney': 'Αρχειοθέτηση Ταξιδιού', 'journey.settings.reopenJourney': 'Επαναφορά Ταξιδιού', 'journey.settings.archived': 'Το ταξίδι αρχειοθετήθηκε', diff --git a/shared/src/i18n/hu/journey.ts b/shared/src/i18n/hu/journey.ts index 1c55b9b05..6f62cffd3 100644 --- a/shared/src/i18n/hu/journey.ts +++ b/shared/src/i18n/hu/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatikus', 'journey.settings.statusAutoHint': 'A kapcsolt utak dátumait követi. Út nélkül a napló piszkozat marad.', 'journey.settings.statusManualHint': 'Kézzel beállítva. Az út dátumai nem változtatják meg, amíg vissza nem váltasz automatikusra.', + 'journey.settings.photosSection': 'Fotók', + 'journey.settings.photoLocation': 'Bejegyzések helye a fotóik alapján', + 'journey.settings.photoLocationHint': 'A hely nélküli bejegyzés az első GPS-es fotója készítési helyét kapja. A kézzel megadott helyek sosem mozdulnak.', 'journey.settings.endJourney': 'Út archiválása', 'journey.settings.reopenJourney': 'Út visszaállítása', 'journey.settings.archived': 'Út archiválva', diff --git a/shared/src/i18n/id/journey.ts b/shared/src/i18n/id/journey.ts index 66706161f..2636355e8 100644 --- a/shared/src/i18n/id/journey.ts +++ b/shared/src/i18n/id/journey.ts @@ -201,6 +201,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Otomatis', 'journey.settings.statusAutoHint': 'Mengikuti tanggal perjalanan yang ditautkan. Tanpa perjalanan, jurnal tetap berupa draf.', 'journey.settings.statusManualHint': 'Diatur manual. Tanggal perjalanan tidak lagi mengubahnya sampai kamu kembali ke otomatis.', + 'journey.settings.photosSection': 'Foto', + 'journey.settings.photoLocation': 'Tentukan lokasi entri dari fotonya', + 'journey.settings.photoLocationHint': 'Entri tanpa lokasi memakai titik tempat foto pertamanya yang ber-GPS diambil. Lokasi yang kamu atur sendiri tidak pernah dipindah.', 'journey.settings.endJourney': 'Arsipkan Perjalanan', 'journey.settings.reopenJourney': 'Pulihkan Perjalanan', 'journey.settings.archived': 'Perjalanan diarsipkan', diff --git a/shared/src/i18n/it/journey.ts b/shared/src/i18n/it/journey.ts index ccf0e8c83..d5b68dbed 100644 --- a/shared/src/i18n/it/journey.ts +++ b/shared/src/i18n/it/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatico', 'journey.settings.statusAutoHint': 'Segue le date dei viaggi collegati. Senza viaggio il diario resta una bozza.', 'journey.settings.statusManualHint': 'Impostato a mano. Le date del viaggio non lo cambiano più finché non torni ad automatico.', + 'journey.settings.photosSection': 'Foto', + 'journey.settings.photoLocation': 'Posiziona le voci dalle loro foto', + 'journey.settings.photoLocationHint': 'Una voce senza luogo prende il punto in cui è stata scattata la sua prima foto con GPS. I luoghi impostati da te non vengono mai spostati.', 'journey.settings.endJourney': 'Archivia il viaggio', 'journey.settings.reopenJourney': 'Ripristina il viaggio', 'journey.settings.archived': 'Viaggio archiviato', diff --git a/shared/src/i18n/ja/journey.ts b/shared/src/i18n/ja/journey.ts index 01d01bdb7..f418f1a6e 100644 --- a/shared/src/i18n/ja/journey.ts +++ b/shared/src/i18n/ja/journey.ts @@ -204,6 +204,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': '自動', 'journey.settings.statusAutoHint': 'リンクした旅行の日付に従います。旅行がない場合は下書きのままです。', 'journey.settings.statusManualHint': '手動で設定されています。自動に戻すまで旅行の日付では変わりません。', + 'journey.settings.photosSection': '写真', + 'journey.settings.photoLocation': '写真から記録の場所を設定', + 'journey.settings.photoLocationHint': '場所のない記録は、GPS付きの最初の写真が撮られた地点になります。自分で設定した場所は変更されません。', 'journey.settings.endJourney': '日記をアーカイブ', 'journey.settings.reopenJourney': '日記を復元', 'journey.settings.archived': '日記をアーカイブしました', diff --git a/shared/src/i18n/ko/journey.ts b/shared/src/i18n/ko/journey.ts index df139cb8a..6e8107c6d 100644 --- a/shared/src/i18n/ko/journey.ts +++ b/shared/src/i18n/ko/journey.ts @@ -203,6 +203,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': '자동', 'journey.settings.statusAutoHint': '연결된 여행의 날짜를 따릅니다. 여행이 없으면 초안으로 남습니다.', 'journey.settings.statusManualHint': '직접 설정했습니다. 자동으로 되돌릴 때까지 여행 날짜로 바뀌지 않습니다.', + 'journey.settings.photosSection': '사진', + 'journey.settings.photoLocation': '사진으로 기록 위치 지정', + 'journey.settings.photoLocationHint': '위치가 없는 기록은 GPS가 있는 첫 사진을 찍은 곳으로 지정됩니다. 직접 정한 위치는 바뀌지 않습니다.', 'journey.settings.endJourney': 'Journey 보관', 'journey.settings.reopenJourney': 'Journey 복원', 'journey.settings.archived': 'Journey가 보관되었습니다', diff --git a/shared/src/i18n/nl/journey.ts b/shared/src/i18n/nl/journey.ts index 44ac8e41f..32be4f0d1 100644 --- a/shared/src/i18n/nl/journey.ts +++ b/shared/src/i18n/nl/journey.ts @@ -203,6 +203,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatisch', 'journey.settings.statusAutoHint': 'Volgt de data van de gekoppelde trips. Zonder trip blijft de journey een concept.', 'journey.settings.statusManualHint': 'Handmatig ingesteld. De tripdata veranderen het niet meer tot je terugzet op automatisch.', + 'journey.settings.photosSection': "Foto's", + 'journey.settings.photoLocation': "Items plaatsen op basis van hun foto's", + 'journey.settings.photoLocationHint': 'Een item zonder plek krijgt de plek waar de eerste foto met gps is gemaakt. Plekken die je zelf instelt, worden nooit verplaatst.', 'journey.settings.endJourney': 'Reis archiveren', 'journey.settings.reopenJourney': 'Reis herstellen', 'journey.settings.archived': 'Reis gearchiveerd', diff --git a/shared/src/i18n/pl/journey.ts b/shared/src/i18n/pl/journey.ts index d065b51f2..b7d71c22f 100644 --- a/shared/src/i18n/pl/journey.ts +++ b/shared/src/i18n/pl/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatycznie', 'journey.settings.statusAutoHint': 'Zależy od dat powiązanych podróży. Bez podróży dziennik pozostaje szkicem.', 'journey.settings.statusManualHint': 'Ustawiony ręcznie. Daty podróży go nie zmienią, dopóki nie wrócisz do trybu automatycznego.', + 'journey.settings.photosSection': 'Zdjęcia', + 'journey.settings.photoLocation': 'Ustalaj miejsce wpisów z ich zdjęć', + 'journey.settings.photoLocationHint': 'Wpis bez miejsca dostaje lokalizację pierwszego zdjęcia z GPS. Miejsca ustawione ręcznie nigdy nie są przesuwane.', 'journey.settings.endJourney': 'Archiwizuj podróż', 'journey.settings.reopenJourney': 'Przywróć podróż', 'journey.settings.archived': 'Podróż zarchiwizowana', diff --git a/shared/src/i18n/ru/journey.ts b/shared/src/i18n/ru/journey.ts index 432839ae7..4a60c0e0e 100644 --- a/shared/src/i18n/ru/journey.ts +++ b/shared/src/i18n/ru/journey.ts @@ -202,6 +202,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Автоматически', 'journey.settings.statusAutoHint': 'Следует датам связанных поездок. Без поездки журнал остаётся черновиком.', 'journey.settings.statusManualHint': 'Задан вручную. Даты поездки больше не меняют его, пока вы не вернёте автоматический режим.', + 'journey.settings.photosSection': 'Фото', + 'journey.settings.photoLocation': 'Определять место записей по фото', + 'journey.settings.photoLocationHint': 'Запись без места получает точку, где сделано её первое фото с GPS. Места, заданные вручную, не меняются.', 'journey.settings.endJourney': 'Архивировать путешествие', 'journey.settings.reopenJourney': 'Восстановить путешествие', 'journey.settings.archived': 'Путешествие архивировано', diff --git a/shared/src/i18n/sk/journey.ts b/shared/src/i18n/sk/journey.ts index 5c7b17e96..8b19efd09 100644 --- a/shared/src/i18n/sk/journey.ts +++ b/shared/src/i18n/sk/journey.ts @@ -214,6 +214,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automaticky', 'journey.settings.statusAutoHint': 'Riadi sa dátumami prepojených výletov. Bez výletu zostane denník konceptom.', 'journey.settings.statusManualHint': 'Nastavené ručne. Dátumy výletu ho nezmenia, kým neprepnete späť na automaticky.', + 'journey.settings.photosSection': 'Fotky', + 'journey.settings.photoLocation': 'Umiestniť záznamy podľa ich fotiek', + 'journey.settings.photoLocationHint': 'Záznam bez miesta prevezme miesto, kde vznikla jeho prvá fotka s GPS. Ručne nastavené miesta sa nikdy nepresunú.', 'journey.settings.endJourney': 'Archivovať cestu', 'journey.settings.reopenJourney': 'Obnoviť cestu', 'journey.settings.archived': 'Cesta archivovaná', diff --git a/shared/src/i18n/sv/journey.ts b/shared/src/i18n/sv/journey.ts index c9eb8d900..0ee55952e 100644 --- a/shared/src/i18n/sv/journey.ts +++ b/shared/src/i18n/sv/journey.ts @@ -207,6 +207,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Automatiskt', 'journey.settings.statusAutoHint': 'Följer datumen för de länkade resorna. Utan resa förblir resedagboken ett utkast.', 'journey.settings.statusManualHint': 'Satt för hand. Resans datum ändrar det inte längre förrän du växlar tillbaka till automatiskt.', + 'journey.settings.photosSection': 'Foton', + 'journey.settings.photoLocation': 'Placera inlägg utifrån deras foton', + 'journey.settings.photoLocationHint': 'Ett inlägg utan plats får platsen där dess första foto med GPS togs. Platser du själv satt flyttas aldrig.', 'journey.settings.endJourney': 'Arkivera Journey', 'journey.settings.reopenJourney': 'Återställ Journey', 'journey.settings.archived': 'Journey arkiverad', diff --git a/shared/src/i18n/th/journey.ts b/shared/src/i18n/th/journey.ts index 9d9452c1b..715c4b0a1 100644 --- a/shared/src/i18n/th/journey.ts +++ b/shared/src/i18n/th/journey.ts @@ -214,6 +214,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'อัตโนมัติ', 'journey.settings.statusAutoHint': 'ตามวันที่ของทริปที่เชื่อมไว้ หากไม่มีทริป บันทึกจะเป็นฉบับร่าง', 'journey.settings.statusManualHint': 'ตั้งเอง วันที่ของทริปจะไม่เปลี่ยนสถานะนี้จนกว่าจะกลับเป็นอัตโนมัติ', + 'journey.settings.photosSection': 'รูปภาพ', + 'journey.settings.photoLocation': 'ระบุตำแหน่งบันทึกจากรูปภาพ', + 'journey.settings.photoLocationHint': 'บันทึกที่ไม่มีสถานที่จะใช้ตำแหน่งที่ถ่ายรูปแรกที่มี GPS สถานที่ที่คุณตั้งเองจะไม่ถูกย้าย', 'journey.settings.endJourney': "ปิดการเดินทาง", 'journey.settings.reopenJourney': "เปิดการเดินทางอีกครั้ง", 'journey.settings.archived': "เก็บถาวรการเดินทางแล้ว", diff --git a/shared/src/i18n/tr/journey.ts b/shared/src/i18n/tr/journey.ts index 7215f5e3a..37c4a6d45 100644 --- a/shared/src/i18n/tr/journey.ts +++ b/shared/src/i18n/tr/journey.ts @@ -204,6 +204,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Otomatik', 'journey.settings.statusAutoHint': 'Bağlı gezilerin tarihlerini izler. Gezi olmadan günlük taslak kalır.', 'journey.settings.statusManualHint': 'Elle ayarlandı. Otomatiğe dönene kadar gezi tarihleri bunu değiştirmez.', + 'journey.settings.photosSection': 'Fotoğraflar', + 'journey.settings.photoLocation': 'Kayıtları fotoğraflarından konumlandır', + 'journey.settings.photoLocationHint': "Konumu olmayan bir kayıt, GPS'li ilk fotoğrafının çekildiği yeri alır. Kendi belirlediğin yerler asla taşınmaz.", 'journey.settings.endJourney': "Journey'i Arşivle", 'journey.settings.reopenJourney': "Journey'i geri aç", 'journey.settings.archived': 'Journey arşivlendi', diff --git a/shared/src/i18n/uk/journey.ts b/shared/src/i18n/uk/journey.ts index 34a422031..3cc969ac4 100644 --- a/shared/src/i18n/uk/journey.ts +++ b/shared/src/i18n/uk/journey.ts @@ -204,6 +204,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Автоматично', 'journey.settings.statusAutoHint': "Залежить від дат пов'язаних поїздок. Без поїздки журнал лишається чернеткою.", 'journey.settings.statusManualHint': 'Встановлено вручну. Дати поїздки більше не змінюють його, доки не повернете автоматичний режим.', + 'journey.settings.photosSection': 'Фото', + 'journey.settings.photoLocation': 'Визначати місце записів за фото', + 'journey.settings.photoLocationHint': 'Запис без місця отримує точку, де зроблено його перше фото з GPS. Місця, задані вручну, ніколи не змінюються.', 'journey.settings.endJourney': 'Архівувати подорож', 'journey.settings.reopenJourney': 'Відновити подорож', 'journey.settings.archived': 'Подорож архівовано', diff --git a/shared/src/i18n/vi/journey.ts b/shared/src/i18n/vi/journey.ts index acb566f25..314f24ef1 100644 --- a/shared/src/i18n/vi/journey.ts +++ b/shared/src/i18n/vi/journey.ts @@ -207,6 +207,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': 'Tự động', 'journey.settings.statusAutoHint': 'Theo ngày của các chuyến đi đã liên kết. Không có chuyến đi thì nhật ký vẫn là bản nháp.', 'journey.settings.statusManualHint': 'Đặt thủ công. Ngày của chuyến đi sẽ không đổi nó nữa cho đến khi bạn chuyển lại tự động.', + 'journey.settings.photosSection': 'Ảnh', + 'journey.settings.photoLocation': 'Định vị mục theo ảnh của nó', + 'journey.settings.photoLocationHint': 'Mục chưa có địa điểm sẽ lấy nơi chụp ảnh có GPS đầu tiên của nó. Địa điểm bạn tự đặt không bao giờ bị đổi.', 'journey.settings.endJourney': 'Hành trình lưu trữ', 'journey.settings.reopenJourney': 'Khôi phục hành trình', 'journey.settings.archived': 'Đã lưu trữ hành trình', diff --git a/shared/src/i18n/zh-TW/journey.ts b/shared/src/i18n/zh-TW/journey.ts index f2122632e..6065813bf 100644 --- a/shared/src/i18n/zh-TW/journey.ts +++ b/shared/src/i18n/zh-TW/journey.ts @@ -200,6 +200,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': '自動', 'journey.settings.statusAutoHint': '跟隨連結行程的日期。沒有行程時,旅記維持為草稿。', 'journey.settings.statusManualHint': '已手動設定。在切回自動之前,行程日期不會再改變它。', + 'journey.settings.photosSection': '照片', + 'journey.settings.photoLocation': '依照片定位條目', + 'journey.settings.photoLocationHint': '沒有地點的條目會使用其第一張帶 GPS 照片的拍攝位置。你手動設定的地點永遠不會被更動。', 'journey.settings.endJourney': '封存旅程', 'journey.settings.reopenJourney': '還原旅程', 'journey.settings.archived': '旅程已封存', diff --git a/shared/src/i18n/zh/journey.ts b/shared/src/i18n/zh/journey.ts index a5834a220..c083433b8 100644 --- a/shared/src/i18n/zh/journey.ts +++ b/shared/src/i18n/zh/journey.ts @@ -200,6 +200,9 @@ const journey: TranslationStrings = { 'journey.settings.statusAuto': '自动', 'journey.settings.statusAutoHint': '跟随关联行程的日期。没有行程时,旅记保持为草稿。', 'journey.settings.statusManualHint': '已手动设置。在切回自动之前,行程日期不会再改变它。', + 'journey.settings.photosSection': '照片', + 'journey.settings.photoLocation': '根据照片定位条目', + 'journey.settings.photoLocationHint': '没有地点的条目会使用其第一张带 GPS 照片的拍摄位置。你手动设置的地点永远不会被改动。', 'journey.settings.endJourney': '归档旅程', 'journey.settings.reopenJourney': '恢复旅程', 'journey.settings.archived': '旅程已归档',