diff --git a/.megamemory/knowledge.db b/.megamemory/knowledge.db index 0749e756..a1a9c970 100644 Binary files a/.megamemory/knowledge.db and b/.megamemory/knowledge.db differ diff --git a/.megamemory/knowledge.db-shm b/.megamemory/knowledge.db-shm index fe9ac284..5257bf6c 100644 Binary files a/.megamemory/knowledge.db-shm and b/.megamemory/knowledge.db-shm differ diff --git a/.megamemory/knowledge.db-wal b/.megamemory/knowledge.db-wal index e69de29b..69e8f9bb 100644 Binary files a/.megamemory/knowledge.db-wal and b/.megamemory/knowledge.db-wal differ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e69de29b diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py index ad2c94c2..47147082 100644 --- a/backend/server/adventures/geocoding.py +++ b/backend/server/adventures/geocoding.py @@ -6,33 +6,37 @@ import unicodedata from worldtravel.models import Region, City, VisitedRegion, VisitedCity from django.conf import settings + # ----------------- # SEARCHING -def search_google(query): +def search_google(query, lang="en"): try: api_key = settings.GOOGLE_MAPS_API_KEY if not api_key: - return {"error": "Geocoding service unavailable. Please check configuration."} + return { + "error": "Geocoding service unavailable. Please check configuration." + } # Updated to use the new Places API (New) endpoint url = "https://places.googleapis.com/v1/places:searchText" - + headers = { - 'Content-Type': 'application/json', - 'X-Goog-Api-Key': api_key, - 'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount' + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + "X-Goog-FieldMask": "places.displayName.text,places.formattedAddress,places.location,places.types,places.rating,places.userRatingCount", } - + payload = { "textQuery": query, - "maxResultCount": 20 # Adjust as needed + "maxResultCount": 20, # Adjust as needed + "languageCode": lang, } - + response = requests.post(url, json=payload, headers=headers, timeout=(2, 5)) response.raise_for_status() data = response.json() - + # Check if we have places in the response places = data.get("places", []) if not places: @@ -56,42 +60,62 @@ def search_google(query): display_name_obj = place.get("displayName", {}) name = display_name_obj.get("text") if display_name_obj else None - results.append({ - "lat": location.get("latitude"), - "lon": location.get("longitude"), - "name": name, - "display_name": place.get("formattedAddress"), - "type": primary_type, - "category": category, - "importance": importance, - "addresstype": addresstype, - "powered_by": "google", - }) + results.append( + { + "lat": location.get("latitude"), + "lon": location.get("longitude"), + "name": name, + "display_name": place.get("formattedAddress"), + "type": primary_type, + "category": category, + "importance": importance, + "addresstype": addresstype, + "powered_by": "google", + } + ) if results: - results.sort(key=lambda r: r["importance"] if r["importance"] is not None else 0, reverse=True) + results.sort( + key=lambda r: r["importance"] if r["importance"] is not None else 0, + reverse=True, + ) return results except requests.exceptions.Timeout: - return {"error": "Request timed out while contacting Google Maps. Please try again."} + return { + "error": "Request timed out while contacting Google Maps. Please try again." + } except requests.exceptions.ConnectionError: - return {"error": "Unable to connect to Google Maps service. Please check your internet connection."} + return { + "error": "Unable to connect to Google Maps service. Please check your internet connection." + } except requests.exceptions.HTTPError as e: if response.status_code == 400: return {"error": "Invalid request to Google Maps. Please check your query."} elif response.status_code == 401: - return {"error": "Authentication failed with Google Maps. Please check API configuration."} + return { + "error": "Authentication failed with Google Maps. Please check API configuration." + } elif response.status_code == 403: - return {"error": "Access forbidden to Google Maps. Please check API permissions."} + return { + "error": "Access forbidden to Google Maps. Please check API permissions." + } elif response.status_code == 429: - return {"error": "Too many requests to Google Maps. Please try again later."} + return { + "error": "Too many requests to Google Maps. Please try again later." + } else: return {"error": "Google Maps service error. Please try again later."} except requests.exceptions.RequestException: - return {"error": "Network error while contacting Google Maps. Please try again."} + return { + "error": "Network error while contacting Google Maps. Please try again." + } except Exception: - return {"error": "An unexpected error occurred during Google search. Please try again."} + return { + "error": "An unexpected error occurred during Google search. Please try again." + } + def _extract_google_category(types): # Basic category inference based on common place types @@ -126,56 +150,74 @@ def _infer_addresstype(type_): return mapping.get(type_, None) -def search_osm(query): +def search_osm(query, lang="en"): try: url = f"https://nominatim.openstreetmap.org/search?q={query}&format=jsonv2" - headers = {'User-Agent': 'Voyage Server'} + headers = {"User-Agent": "Voyage Server", "Accept-Language": lang} response = requests.get(url, headers=headers, timeout=(2, 5)) response.raise_for_status() data = response.json() - return [{ - "lat": item.get("lat"), - "lon": item.get("lon"), - "name": item.get("name"), - "display_name": item.get("display_name"), - "type": item.get("type"), - "category": item.get("category"), - "importance": item.get("importance"), - "addresstype": item.get("addresstype"), - "powered_by": "nominatim", - } for item in data] + return [ + { + "lat": item.get("lat"), + "lon": item.get("lon"), + "name": item.get("name"), + "display_name": item.get("display_name"), + "type": item.get("type"), + "category": item.get("category"), + "importance": item.get("importance"), + "addresstype": item.get("addresstype"), + "powered_by": "nominatim", + } + for item in data + ] except requests.exceptions.Timeout: - return {"error": "Request timed out while contacting OpenStreetMap. Please try again."} + return { + "error": "Request timed out while contacting OpenStreetMap. Please try again." + } except requests.exceptions.ConnectionError: - return {"error": "Unable to connect to OpenStreetMap service. Please check your internet connection."} + return { + "error": "Unable to connect to OpenStreetMap service. Please check your internet connection." + } except requests.exceptions.HTTPError as e: if response.status_code == 400: - return {"error": "Invalid request to OpenStreetMap. Please check your query."} + return { + "error": "Invalid request to OpenStreetMap. Please check your query." + } elif response.status_code == 429: - return {"error": "Too many requests to OpenStreetMap. Please try again later."} + return { + "error": "Too many requests to OpenStreetMap. Please try again later." + } else: return {"error": "OpenStreetMap service error. Please try again later."} except requests.exceptions.RequestException: - return {"error": "Network error while contacting OpenStreetMap. Please try again."} + return { + "error": "Network error while contacting OpenStreetMap. Please try again." + } except Exception: - return {"error": "An unexpected error occurred during OpenStreetMap search. Please try again."} + return { + "error": "An unexpected error occurred during OpenStreetMap search. Please try again." + } + def search(query): """ Unified search function that tries Google Maps first, then falls back to OpenStreetMap. """ - if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): + if getattr(settings, "GOOGLE_MAPS_API_KEY", None): google_result = search_google(query) if "error" not in google_result: return google_result # If Google fails, fallback to OSM return search_osm(query) + # ----------------- # REVERSE GEOCODING # ----------------- + def extractIsoCode(user, data): """ Extract the ISO code from the response data. @@ -188,10 +230,10 @@ def extractIsoCode(user, data): visited_city = None location_name = None - if 'name' in data.keys(): - location_name = data['name'] + if "name" in data.keys(): + location_name = data["name"] - address = data.get('address', {}) or {} + address = data.get("address", {}) or {} # Capture country code early for ISO selection and name fallback. country_code = address.get("ISO3166-1") @@ -275,17 +317,17 @@ def extractIsoCode(user, data): # ordered preference for best-effort locality matching locality_keys = [ - 'suburb', - 'neighbourhood', - 'neighborhood', # alternate spelling - 'city', - 'city_district', - 'town', - 'village', - 'hamlet', - 'locality', - 'municipality', - 'county', + "suburb", + "neighbourhood", + "neighborhood", # alternate spelling + "city", + "city_district", + "town", + "village", + "hamlet", + "locality", + "municipality", + "county", ] def _normalize_name(value): @@ -305,13 +347,13 @@ def extractIsoCode(user, data): return exact_match normalized_value = _normalize_name(value) - for candidate in qs.values_list('id', 'name'): + for candidate in qs.values_list("id", "name"): candidate_id, candidate_name = candidate if _normalize_name(candidate_name) == normalized_value: return qs.filter(id=candidate_id).first() # Allow partial matching for most locality fields but keep county strict. - if key_name == 'county': + if key_name == "county": return None return qs.filter(name__icontains=value).first() @@ -333,7 +375,9 @@ def extractIsoCode(user, data): region_visited = bool(visited_region) if city: - display_name = f"{city.name}, {region.name}, {country_code or region.country.country_code}" + display_name = ( + f"{city.name}, {region.name}, {country_code or region.country.country_code}" + ) visited_city = VisitedCity.objects.filter(city=city, user=user).first() city_visited = bool(visited_city) else: @@ -349,9 +393,10 @@ def extractIsoCode(user, data): "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited, - 'location_name': location_name, + "location_name": location_name, } + def is_host_resolvable(hostname: str) -> bool: try: socket.gethostbyname(hostname) @@ -359,8 +404,9 @@ def is_host_resolvable(hostname: str) -> bool: except socket.error: return False + def reverse_geocode(lat, lon, user): - if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): + if getattr(settings, "GOOGLE_MAPS_API_KEY", None): google_result = reverse_geocode_google(lat, lon, user) if "error" not in google_result: return google_result @@ -368,39 +414,59 @@ def reverse_geocode(lat, lon, user): return reverse_geocode_osm(lat, lon, user) return reverse_geocode_osm(lat, lon, user) + def reverse_geocode_osm(lat, lon, user): - url = f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" - headers = {'User-Agent': 'Voyage Server'} + url = ( + f"https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat={lat}&lon={lon}" + ) + headers = {"User-Agent": "Voyage Server"} connect_timeout = 1 read_timeout = 5 if not is_host_resolvable("nominatim.openstreetmap.org"): - return {"error": "Unable to resolve OpenStreetMap service. Please check your internet connection."} + return { + "error": "Unable to resolve OpenStreetMap service. Please check your internet connection." + } try: - response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout)) + response = requests.get( + url, headers=headers, timeout=(connect_timeout, read_timeout) + ) response.raise_for_status() data = response.json() return extractIsoCode(user, data) except requests.exceptions.Timeout: - return {"error": "Request timed out while contacting OpenStreetMap. Please try again."} + return { + "error": "Request timed out while contacting OpenStreetMap. Please try again." + } except requests.exceptions.ConnectionError: - return {"error": "Unable to connect to OpenStreetMap service. Please check your internet connection."} + return { + "error": "Unable to connect to OpenStreetMap service. Please check your internet connection." + } except requests.exceptions.HTTPError as e: if response.status_code == 400: - return {"error": "Invalid request to OpenStreetMap. Please check coordinates."} + return { + "error": "Invalid request to OpenStreetMap. Please check coordinates." + } elif response.status_code == 429: - return {"error": "Too many requests to OpenStreetMap. Please try again later."} + return { + "error": "Too many requests to OpenStreetMap. Please try again later." + } else: return {"error": "OpenStreetMap service error. Please try again later."} except requests.exceptions.RequestException: - return {"error": "Network error while contacting OpenStreetMap. Please try again."} + return { + "error": "Network error while contacting OpenStreetMap. Please try again." + } except Exception: - return {"error": "An unexpected error occurred during OpenStreetMap geocoding. Please try again."} + return { + "error": "An unexpected error occurred during OpenStreetMap geocoding. Please try again." + } + def reverse_geocode_google(lat, lon, user): api_key = settings.GOOGLE_MAPS_API_KEY - + # Updated to use the new Geocoding API endpoint (this one is still supported) # The Geocoding API is separate from Places API and still uses the old format url = "https://maps.googleapis.com/maps/api/geocode/json" @@ -416,11 +482,17 @@ def reverse_geocode_google(lat, lon, user): if status == "ZERO_RESULTS": return {"error": "No location found for the given coordinates."} elif status == "OVER_QUERY_LIMIT": - return {"error": "Query limit exceeded for Google Maps. Please try again later."} + return { + "error": "Query limit exceeded for Google Maps. Please try again later." + } elif status == "REQUEST_DENIED": - return {"error": "Request denied by Google Maps. Please check API configuration."} + return { + "error": "Request denied by Google Maps. Please check API configuration." + } elif status == "INVALID_REQUEST": - return {"error": "Invalid request to Google Maps. Please check coordinates."} + return { + "error": "Invalid request to Google Maps. Please check coordinates." + } else: return {"error": "Geocoding failed. Please try again."} @@ -428,28 +500,47 @@ def reverse_geocode_google(lat, lon, user): first_result = data.get("results", [])[0] result_data = { "name": first_result.get("formatted_address"), - "address": _parse_google_address_components(first_result.get("address_components", [])) + "address": _parse_google_address_components( + first_result.get("address_components", []) + ), } return extractIsoCode(user, result_data) except requests.exceptions.Timeout: - return {"error": "Request timed out while contacting Google Maps. Please try again."} + return { + "error": "Request timed out while contacting Google Maps. Please try again." + } except requests.exceptions.ConnectionError: - return {"error": "Unable to connect to Google Maps service. Please check your internet connection."} + return { + "error": "Unable to connect to Google Maps service. Please check your internet connection." + } except requests.exceptions.HTTPError as e: if response.status_code == 400: - return {"error": "Invalid request to Google Maps. Please check coordinates."} + return { + "error": "Invalid request to Google Maps. Please check coordinates." + } elif response.status_code == 401: - return {"error": "Authentication failed with Google Maps. Please check API configuration."} + return { + "error": "Authentication failed with Google Maps. Please check API configuration." + } elif response.status_code == 403: - return {"error": "Access forbidden to Google Maps. Please check API permissions."} + return { + "error": "Access forbidden to Google Maps. Please check API permissions." + } elif response.status_code == 429: - return {"error": "Too many requests to Google Maps. Please try again later."} + return { + "error": "Too many requests to Google Maps. Please try again later." + } else: return {"error": "Google Maps service error. Please try again later."} except requests.exceptions.RequestException: - return {"error": "Network error while contacting Google Maps. Please try again."} + return { + "error": "Network error while contacting Google Maps. Please try again." + } except Exception: - return {"error": "An unexpected error occurred during Google geocoding. Please try again."} + return { + "error": "An unexpected error occurred during Google geocoding. Please try again." + } + def _parse_google_address_components(components): parsed = {} @@ -476,7 +567,9 @@ def _parse_google_address_components(components): parsed["city"] = long_name if "postal_town" in types: parsed.setdefault("city", long_name) - if "sublocality" in types or any(t.startswith("sublocality_level_") for t in types): + if "sublocality" in types or any( + t.startswith("sublocality_level_") for t in types + ): parsed["suburb"] = long_name if "neighborhood" in types: parsed["neighbourhood"] = long_name diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py index b0635300..43e68149 100644 --- a/backend/server/adventures/views/reverse_geocode_view.py +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -9,41 +9,51 @@ from adventures.geocoding import reverse_geocode from django.conf import settings from adventures.geocoding import search_google, search_osm + class ReverseGeocodeViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] - @action(detail=False, methods=['get']) + @action(detail=False, methods=["get"]) def reverse_geocode(self, request): - lat = request.query_params.get('lat', '') - lon = request.query_params.get('lon', '') + lat = request.query_params.get("lat", "") + lon = request.query_params.get("lon", "") if not lat or not lon: - return Response({"error": "Latitude and longitude are required"}, status=400) + return Response( + {"error": "Latitude and longitude are required"}, status=400 + ) try: lat = float(lat) lon = float(lon) except ValueError: return Response({"error": "Invalid latitude or longitude"}, status=400) data = reverse_geocode(lat, lon, self.request.user) - if 'error' in data: - return Response({"error": "An internal error occurred while processing the request"}, status=400) + if "error" in data: + return Response( + {"error": "An internal error occurred while processing the request"}, + status=400, + ) return Response(data) - - @action(detail=False, methods=['get']) + + @action(detail=False, methods=["get"]) def search(self, request): - query = request.query_params.get('query', '') + query = request.query_params.get("query", "") + lang = request.query_params.get("lang", "en") if not query: return Response({"error": "Query parameter is required"}, status=400) try: - if getattr(settings, 'GOOGLE_MAPS_API_KEY', None): - results = search_google(query) + if getattr(settings, "GOOGLE_MAPS_API_KEY", None): + results = search_google(query, lang=lang) else: - results = search_osm(query) + results = search_osm(query, lang=lang) return Response(results) except Exception: - return Response({"error": "An internal error occurred while processing the request"}, status=500) + return Response( + {"error": "An internal error occurred while processing the request"}, + status=500, + ) - @action(detail=False, methods=['post']) + @action(detail=False, methods=["post"]) def mark_visited_region(self, request): """ Marks regions and cities as visited based on user's visited locations. @@ -53,37 +63,36 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): new_regions = {} new_city_count = 0 new_cities = {} - + # Get all visited locations with their region and city data visited_locations = Location.objects.filter( user=self.request.user - ).select_related('region', 'city') - + ).select_related("region", "city") + # Track unique regions and cities to create VisitedRegion/VisitedCity entries regions_to_mark = set() cities_to_mark = set() - + for location in visited_locations: # Only process locations that are marked as visited if not location.is_visited_status(): continue - + # Collect regions if location.region: regions_to_mark.add(location.region.id) - + # Collect cities if location.city: cities_to_mark.add(location.city.id) - + # Get existing visited regions for this user existing_visited_regions = set( VisitedRegion.objects.filter( - user=self.request.user, - region_id__in=regions_to_mark - ).values_list('region_id', flat=True) + user=self.request.user, region_id__in=regions_to_mark + ).values_list("region_id", flat=True) ) - + # Create new VisitedRegion entries new_visited_regions = [] for region_id in regions_to_mark: @@ -91,7 +100,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): new_visited_regions.append( VisitedRegion(region_id=region_id, user=self.request.user) ) - + if new_visited_regions: VisitedRegion.objects.bulk_create(new_visited_regions) new_region_count = len(new_visited_regions) @@ -100,15 +109,14 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): id__in=[vr.region_id for vr in new_visited_regions] ) new_regions = {r.id: r.name for r in regions} - + # Get existing visited cities for this user existing_visited_cities = set( VisitedCity.objects.filter( - user=self.request.user, - city_id__in=cities_to_mark - ).values_list('city_id', flat=True) + user=self.request.user, city_id__in=cities_to_mark + ).values_list("city_id", flat=True) ) - + # Create new VisitedCity entries new_visited_cities = [] for city_id in cities_to_mark: @@ -116,7 +124,7 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): new_visited_cities.append( VisitedCity(city_id=city_id, user=self.request.user) ) - + if new_visited_cities: VisitedCity.objects.bulk_create(new_visited_cities) new_city_count = len(new_visited_cities) @@ -125,10 +133,12 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): id__in=[vc.city_id for vc in new_visited_cities] ) new_cities = {c.id: c.name for c in cities} - - return Response({ - "new_regions": new_region_count, - "regions": new_regions, - "new_cities": new_city_count, - "cities": new_cities - }) \ No newline at end of file + + return Response( + { + "new_regions": new_region_count, + "regions": new_regions, + "new_cities": new_city_count, + "cities": new_cities, + } + ) diff --git a/frontend/src/lib/components/StravaActivityCard.svelte b/frontend/src/lib/components/StravaActivityCard.svelte index 8598d8ff..5d2a4b63 100644 --- a/frontend/src/lib/components/StravaActivityCard.svelte +++ b/frontend/src/lib/components/StravaActivityCard.svelte @@ -42,12 +42,13 @@ } function formatDate(dateString: string): string { - return new Date(dateString).toLocaleDateString('en-US', { + return new Date(dateString).toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', - minute: '2-digit' + minute: '2-digit', + hour12: false }); } diff --git a/frontend/src/lib/components/cards/ChecklistCard.svelte b/frontend/src/lib/components/cards/ChecklistCard.svelte index aef3c290..d575e803 100644 --- a/frontend/src/lib/components/cards/ChecklistCard.svelte +++ b/frontend/src/lib/components/cards/ChecklistCard.svelte @@ -178,7 +178,7 @@
- {user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString() : ''} + {user.date_joined ? 'Joined ' + new Date(user.date_joined).toLocaleDateString('en-GB') : ''}