Spaces:
Sleeping
Sleeping
File size: 6,683 Bytes
57a889c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 | import { db, getPlaceWithTags } from '../db/database';
import { broadcast } from '../websocket';
import { getMapsKey, searchPlaces, getPlacePhoto } from './mapsService';
/**
* Background enrichment for list-imported places (#886).
*
* Google/Naver list imports only carry name + coordinates, so the imported
* places open as bare pins (the Maps tab jumps to coordinates, no photo, no
* open/closed). When the importer opts in and a Google Maps key is configured,
* we re-resolve each place by name β biased to and validated against the
* imported coordinates β to a real Google place, then fill in the empty fields
* and persist the resolved `google_place_id` (which is what powers on-demand
* opening hours / the proper Maps link going forward).
*
* This runs detached from the import request (fire-and-forget) so a long list
* never blocks the response, and pushes each enriched row over the websocket so
* the sidebar fills in progressively. It only ever fills EMPTY columns, so it
* can never clobber data the import already captured (e.g. a Naver address).
*/
/** A place the import produced β only the fields enrichment reads/writes. */
export interface EnrichablePlace {
id: number;
name: string;
lat: number;
lng: number;
google_place_id?: string | null;
address?: string | null;
website?: string | null;
phone?: string | null;
image_url?: string | null;
}
/** How close a search hit must be to the imported coordinates to be trusted. */
const MATCH_RADIUS_METERS = 250;
/** Bias the text search to roughly the imported area. */
const SEARCH_BIAS_RADIUS_METERS = 2000;
/** Concurrent enrichment lookups β small, to stay friendly to the Maps quota. */
const ENRICH_CONCURRENCY = 3;
function haversineMeters(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const R = 6371000;
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(b.lat - a.lat);
const dLng = toRad(b.lng - a.lng);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
return 2 * R * Math.asin(Math.sqrt(h));
}
/**
* Pick the search result that is the same place as the import: it must be a
* Google result (have a google_place_id) with coordinates within
* MATCH_RADIUS_METERS of the imported point. Returns the closest such hit, or
* null when nothing is close enough β in which case the place is left as
* imported rather than risking a wrong-place overwrite (common-name / romanized
* lists). Exported for unit testing.
*/
export function pickEnrichmentMatch(
candidates: Record<string, unknown>[],
target: { lat: number; lng: number },
maxMeters: number = MATCH_RADIUS_METERS,
): Record<string, unknown> | null {
let best: { c: Record<string, unknown>; dist: number } | null = null;
for (const c of candidates || []) {
const gpid = c.google_place_id;
const lat = c.lat;
const lng = c.lng;
if (typeof gpid !== 'string' || !gpid) continue;
if (typeof lat !== 'number' || typeof lng !== 'number') continue;
const dist = haversineMeters(target, { lat, lng });
if (dist > maxMeters) continue;
if (!best || dist < best.dist) best = { c, dist };
}
return best?.c ?? null;
}
async function mapWithConcurrency<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
await fn(item);
}
});
await Promise.all(workers);
}
const str = (v: unknown): string | null => (typeof v === 'string' && v.trim() ? v.trim() : null);
async function enrichOne(tripId: string, userId: number, place: EnrichablePlace, lang?: string): Promise<void> {
// Already linked (shouldn't happen for list imports) β nothing to resolve.
if (place.google_place_id) return;
if (typeof place.lat !== 'number' || typeof place.lng !== 'number') return;
const { places: results } = await searchPlaces(userId, place.name, lang, {
lat: place.lat,
lng: place.lng,
radius: SEARCH_BIAS_RADIUS_METERS,
});
const match = pickEnrichmentMatch(results, { lat: place.lat, lng: place.lng });
if (!match) return;
const gpid = str(match.google_place_id);
if (!gpid) return;
// COALESCE so enrichment only fills empty columns β never overwrites data the
// import already captured (e.g. Naver's address) or anything the user edited.
db.prepare(
`UPDATE places
SET google_place_id = COALESCE(google_place_id, ?),
address = COALESCE(address, ?),
website = COALESCE(website, ?),
phone = COALESCE(phone, ?),
updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND trip_id = ?`,
).run(gpid, str(match.address), str(match.website), str(match.phone), place.id, tripId);
// Photo is best-effort: Google often has none, and getPlacePhoto throws 404 in
// that case β a missing photo must never abort the rest of the enrichment.
try {
const photo = await getPlacePhoto(userId, gpid, place.lat, place.lng, place.name);
if (photo?.photoUrl) {
db.prepare(
'UPDATE places SET image_url = COALESCE(image_url, ?), updated_at = CURRENT_TIMESTAMP WHERE id = ? AND trip_id = ?',
).run(photo.photoUrl, place.id, tripId);
}
} catch {
/* no photo β leave image_url as-is */
}
// Push the enriched row to every connected client (no socket exclusion: the
// importer's own client should also receive the late update).
const updated = getPlaceWithTags(place.id);
if (updated) broadcast(tripId, 'place:updated', { place: updated }, undefined);
}
/**
* Enrich a batch of just-imported places in the background. Never throws β
* any per-place failure is swallowed so one bad lookup can't take down the
* detached task or the process. No-ops when no Google Maps key is configured.
*/
export async function enrichImportedPlaces(
tripId: string,
userId: number,
places: EnrichablePlace[],
lang?: string,
): Promise<void> {
try {
if (!places.length) return;
if (!getMapsKey(userId)) return;
await mapWithConcurrency(places, ENRICH_CONCURRENCY, async (place) => {
try {
await enrichOne(tripId, userId, place, lang);
} catch (err) {
console.error(`[Places] enrichment failed for place ${place.id}:`, err instanceof Error ? err.message : err);
}
});
} catch (err) {
console.error('[Places] import enrichment pass failed:', err instanceof Error ? err.message : err);
}
}
|