feat(journey): optionally place an entry where its first photo was taken (#1003)

This commit is contained in:
Maurice
2026-10-01 14:05:47 +02:00
parent f875c85474
commit 81d7022b96
38 changed files with 322 additions and 16 deletions
@@ -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' }))
@@ -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,
</div>
</DialogSection>
{/* Places from photos (#1003): the GPS a picture carries puts its entry on the map */}
<DialogSection label={t('journey.settings.photosSection')}>
<div className={ROWS}>
<div className={`${ROW}${savingPhotoLocation ? ' opacity-60' : ''}`}>
<span className="min-w-0 flex-1">
<span className="block text-content" style={fs(13, 'body')}>{t('journey.settings.photoLocation')}</span>
<span className="block text-content-faint" style={fs(11.5)}>{t('journey.settings.photoLocationHint')}</span>
</span>
<ToggleSwitch
on={!!journey.photo_location}
onToggle={() => { if (!savingPhotoLocation) handlePhotoLocationToggle() }}
label={t('journey.settings.photoLocation')}
/>
</div>
</div>
</DialogSection>
{/* The three fields a journey may put away (discussion #2299) */}
<DialogSection label={t('journey.settings.entryFields')}>
<p className="m-0 mb-2.5 leading-normal text-content-faint" style={fs(11.5)}>{t('journey.settings.entryFieldsHint')}</p>
+2
View File
@@ -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).
*
+10
View File
@@ -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) {
@@ -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<number>();
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) {
@@ -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],
})
@@ -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<number> {
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', {});
}
+2 -1
View File
@@ -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,
) {
+1 -1
View File
@@ -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),
@@ -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);
@@ -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 }));
+3
View File
@@ -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
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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é',
+3
View File
@@ -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': 'Το ταξίδι αρχειοθετήθηκε',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': '日記をアーカイブしました',
+3
View File
@@ -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가 보관되었습니다',
+3
View File
@@ -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',
+3
View File
@@ -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',
+3
View File
@@ -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': 'Путешествие архивировано',
+3
View File
@@ -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á',
+3
View File
@@ -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',
+3
View File
@@ -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': "เก็บถาวรการเดินทางแล้ว",
+3
View File
@@ -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',
+3
View File
@@ -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': 'Подорож архівовано',
+3
View File
@@ -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',
+3
View File
@@ -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': '旅程已封存',
+3
View File
@@ -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': '旅程已归档',