mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(journey): optionally place an entry where its first photo was taken (#1003)
This commit is contained in:
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user