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
+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,
) {