mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(journey): optionally place an entry where its first photo was taken (#1003)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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': 'Το ταξίδι αρχειοθετήθηκε',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '日記をアーカイブしました',
|
||||
|
||||
@@ -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가 보관되었습니다',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Путешествие архивировано',
|
||||
|
||||
@@ -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á',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': "เก็บถาวรการเดินทางแล้ว",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Подорож архівовано',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '旅程已封存',
|
||||
|
||||
@@ -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': '旅程已归档',
|
||||
|
||||
Reference in New Issue
Block a user