diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 806ec340..ec00bf11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -163,7 +163,7 @@ If your changes affect: please update the documentation in the: ``` -/documentation +/docs ``` folder accordingly. diff --git a/backend/server/adventures/geocoding.py b/backend/server/adventures/geocoding.py index fb80c32f..baa03730 100644 --- a/backend/server/adventures/geocoding.py +++ b/backend/server/adventures/geocoding.py @@ -3,6 +3,7 @@ import socket import re import unicodedata +from urllib.parse import quote from worldtravel.models import Region, City, VisitedRegion, VisitedCity from django.conf import settings @@ -20,7 +21,12 @@ def search_google(query): 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' + 'X-Goog-FieldMask': ( + 'places.id,places.displayName.text,places.formattedAddress,places.location,' + 'places.types,places.rating,places.userRatingCount,places.websiteUri,' + 'places.nationalPhoneNumber,places.internationalPhoneNumber,' + 'places.editorialSummary.text,places.googleMapsUri,places.photos.name' + ) } payload = { @@ -52,6 +58,14 @@ def search_google(query): if rating is not None and ratings_total: importance = round(float(rating) * ratings_total / 100, 2) + photos = [] + for photo in place.get('photos', [])[:5]: + photo_name = photo.get('name') + if photo_name: + photos.append( + f"https://places.googleapis.com/v1/{photo_name}/media?key={api_key}&maxHeightPx=800&maxWidthPx=800" + ) + # Extract display name from the new API structure display_name_obj = place.get("displayName", {}) name = display_name_obj.get("text") if display_name_obj else None @@ -61,9 +75,18 @@ def search_google(query): "lon": location.get("longitude"), "name": name, "display_name": place.get("formattedAddress"), + "place_id": place.get("id"), "type": primary_type, + "types": types, "category": category, + "description": (place.get('editorialSummary') or {}).get('text'), + "website": place.get('websiteUri'), + "phone_number": place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber'), + "google_maps_url": place.get('googleMapsUri'), "importance": importance, + "rating": rating, + "review_count": ratings_total, + "photos": photos, "addresstype": addresstype, "powered_by": "google", }) @@ -172,6 +195,359 @@ def search(query): # If Google fails, fallback to OSM return search_osm(query) + +def _fetch_wikipedia_summary(query, language='en'): + normalized_query = (query or '').strip() + if not normalized_query: + return None + + candidates = [normalized_query] + if ',' in normalized_query: + head = normalized_query.split(',')[0].strip() + if head and head not in candidates: + candidates.append(head) + + for candidate in candidates: + try: + encoded_query = quote(candidate, safe='') + url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{encoded_query}" + response = requests.get( + url, + headers={'User-Agent': 'AdventureLog Server'}, + timeout=(2, 5), + ) + if response.status_code != 200: + continue + + data = response.json() + if data.get('type') == 'disambiguation': + continue + + extract = (data.get('extract') or '').strip() + if len(extract) >= 120: + return extract + except requests.exceptions.RequestException: + continue + + return None + + +def _compose_place_description( + editorial_summary, + review_snippets, +): + parts = [] + + summary = (editorial_summary or '').strip() + if summary: + parts.append(f"### About\n\n{summary}") + + cleaned_reviews = [] + for snippet in review_snippets: + text = (snippet or '').strip() + if len(text) >= 40: + cleaned_reviews.append(text) + if len(cleaned_reviews) >= 2: + break + + if cleaned_reviews: + review_block = '### Visitor Highlights\n\n' + '\n'.join( + f"- {text}" for text in cleaned_reviews + ) + parts.append(review_block) + + return '\n\n'.join(parts).strip() or None + + +def get_place_details(place_id, fallback_query=None, language='en'): + if not place_id: + return {'error': 'place_id is required'} + + details = { + 'description': None, + 'name': None, + 'formatted_address': None, + 'types': [], + 'rating': None, + 'review_count': None, + 'website': None, + 'phone_number': None, + 'google_maps_url': None, + 'source': None, + } + + api_key = settings.GOOGLE_MAPS_API_KEY + if api_key: + try: + url = f"https://places.googleapis.com/v1/places/{place_id}" + headers = { + 'X-Goog-Api-Key': api_key, + 'X-Goog-FieldMask': ( + 'id,displayName.text,formattedAddress,editorialSummary.text,types,' + 'rating,userRatingCount,websiteUri,nationalPhoneNumber,' + 'internationalPhoneNumber,googleMapsUri,reviews.text.text' + ), + } + response = requests.get(url, headers=headers, timeout=(2, 6)) + response.raise_for_status() + + place = response.json() + details['name'] = (place.get('displayName') or {}).get('text') + details['formatted_address'] = place.get('formattedAddress') + details['types'] = place.get('types') or [] + details['rating'] = place.get('rating') + details['review_count'] = place.get('userRatingCount') + details['website'] = place.get('websiteUri') + details['phone_number'] = ( + place.get('internationalPhoneNumber') or place.get('nationalPhoneNumber') + ) + details['google_maps_url'] = place.get('googleMapsUri') + + editorial_summary = (place.get('editorialSummary') or {}).get('text') + reviews = place.get('reviews') or [] + review_snippets = [((review.get('text') or {}).get('text')) for review in reviews] + details['description'] = _compose_place_description( + editorial_summary, + review_snippets, + ) + if details['description']: + details['source'] = 'google' + except requests.exceptions.RequestException: + pass + + # Google summaries are often short; fallback to Wikipedia for richer context. + description_text = (details.get('description') or '').strip() + if len(description_text) < 220: + wikipedia_summary = _fetch_wikipedia_summary( + fallback_query or details.get('name') or '', + language=language, + ) + if wikipedia_summary: + if description_text: + details['description'] = f"{description_text}\n\n### Background\n\n{wikipedia_summary}" + details['source'] = 'google+wikipedia' + else: + details['description'] = f"### Background\n\n{wikipedia_summary}" + details['source'] = 'wikipedia' + + if not details.get('description'): + return {'error': 'Unable to enrich place description'} + + return details + + +def _clean_location_candidate(value): + if value is None: + return None + cleaned = str(value).strip() + return cleaned or None + + +def _looks_like_street_address(value): + candidate = _clean_location_candidate(value) + if not candidate: + return False + + lowered = candidate.lower() + if not re.search(r"\d", lowered): + return False + + if lowered.count(",") >= 2: + return True + + if not re.match(r"^\d{1,6}\s+\S+", lowered): + return False + + street_tokens = ( + "st", + "street", + "rd", + "road", + "ave", + "avenue", + "blvd", + "boulevard", + "dr", + "drive", + "ln", + "lane", + "ct", + "court", + "pl", + "place", + "pkwy", + "parkway", + "hwy", + "highway", + "trl", + "trail", + ) + return any(re.search(rf"\b{token}\b", lowered) for token in street_tokens) + + +def _first_preferred_location_name(candidates, allow_address_fallback=False): + address_fallback = None + for candidate in candidates: + cleaned = _clean_location_candidate(candidate) + if not cleaned: + continue + if not _looks_like_street_address(cleaned): + return cleaned + if address_fallback is None: + address_fallback = cleaned + return address_fallback if allow_address_fallback else None + + +def _extract_google_component_name(address_components): + preferred_types = ( + "premise", + "point_of_interest", + "establishment", + "subpremise", + "natural_feature", + "airport", + "park", + "tourist_attraction", + "shopping_mall", + "university", + "school", + "hospital", + ) + + for preferred_type in preferred_types: + for component in address_components or []: + types = component.get("types", []) + if preferred_type in types: + return component.get("long_name") or component.get("short_name") + return None + + +def _score_google_result_types(types): + priority = ( + "point_of_interest", + "establishment", + "premise", + "subpremise", + "tourist_attraction", + "park", + "airport", + "shopping_mall", + "university", + "school", + "hospital", + "street_address", + "route", + ) + for idx, type_name in enumerate(priority): + if type_name in types: + return len(priority) - idx + return 0 + + +def _fetch_google_nearby_place_name(lat, lon, api_key): + url = "https://places.googleapis.com/v1/places:searchNearby" + headers = { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': api_key, + 'X-Goog-FieldMask': 'places.displayName.text,places.formattedAddress,places.types', + } + payload = { + "maxResultCount": 6, + "rankPreference": "DISTANCE", + "locationRestriction": { + "circle": { + "center": { + "latitude": float(lat), + "longitude": float(lon), + }, + "radius": 45.0, + } + }, + } + + try: + response = requests.post(url, headers=headers, json=payload, timeout=(2, 5)) + response.raise_for_status() + places = (response.json() or {}).get("places", []) + except requests.exceptions.RequestException: + return None + + candidates = [((place.get("displayName") or {}).get("text")) for place in places] + return _first_preferred_location_name(candidates, allow_address_fallback=False) + + +def _extract_google_location_name(results, nearby_place_name=None): + preferred_nearby = _first_preferred_location_name([nearby_place_name], allow_address_fallback=False) + if preferred_nearby: + return preferred_nearby + + scored_candidates = [] + for result in results or []: + score = _score_google_result_types(result.get("types", [])) + if score <= 0: + continue + component_name = _extract_google_component_name(result.get("address_components", [])) + name_candidate = _first_preferred_location_name([component_name], allow_address_fallback=False) + if name_candidate: + scored_candidates.append((score, name_candidate)) + + if scored_candidates: + scored_candidates.sort(key=lambda item: item[0], reverse=True) + return scored_candidates[0][1] + + component_candidates = [ + _extract_google_component_name(result.get("address_components", [])) + for result in (results or []) + ] + component_pick = _first_preferred_location_name(component_candidates, allow_address_fallback=False) + if component_pick: + return component_pick + + formatted_candidates = [result.get("formatted_address") for result in (results or [])] + return _first_preferred_location_name(formatted_candidates, allow_address_fallback=True) + + +def _extract_osm_location_name(data): + address = data.get("address", {}) or {} + namedetails = data.get("namedetails", {}) or {} + extratags = data.get("extratags", {}) or {} + + candidates = [ + data.get("name"), + namedetails.get("name"), + namedetails.get("official_name"), + namedetails.get("short_name"), + namedetails.get("brand"), + namedetails.get("loc_name"), + address.get("amenity"), + address.get("tourism"), + address.get("attraction"), + address.get("building"), + address.get("shop"), + address.get("leisure"), + address.get("historic"), + address.get("man_made"), + address.get("office"), + address.get("aeroway"), + address.get("railway"), + address.get("public_transport"), + address.get("craft"), + address.get("house_name"), + extratags.get("name"), + extratags.get("official_name"), + extratags.get("brand"), + extratags.get("operator"), + ] + + preferred = _first_preferred_location_name(candidates, allow_address_fallback=False) + if preferred: + return preferred + + return _first_preferred_location_name( + [data.get("name"), data.get("display_name")], + allow_address_fallback=True, + ) + # ----------------- # REVERSE GEOCODING # ----------------- @@ -186,10 +562,7 @@ def extractIsoCode(user, data): country_code = None city = None visited_city = None - location_name = None - - if 'name' in data.keys(): - location_name = data['name'] + location_name = _clean_location_candidate(data.get('location_name') or data.get('name')) address = data.get('address', {}) or {} @@ -369,7 +742,10 @@ def reverse_geocode(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}" + url = ( + "https://nominatim.openstreetmap.org/reverse" + f"?format=jsonv2&addressdetails=1&namedetails=1&extratags=1&zoom=18&lat={lat}&lon={lon}" + ) headers = {'User-Agent': 'AdventureLog Server'} connect_timeout = 1 read_timeout = 5 @@ -381,6 +757,7 @@ def reverse_geocode_osm(lat, lon, user): response = requests.get(url, headers=headers, timeout=(connect_timeout, read_timeout)) response.raise_for_status() data = response.json() + data["location_name"] = _extract_osm_location_name(data) return extractIsoCode(user, data) except requests.exceptions.Timeout: return {"error": "Request timed out while contacting OpenStreetMap. Please try again."} @@ -424,11 +801,23 @@ def reverse_geocode_google(lat, lon, user): else: return {"error": "Geocoding failed. Please try again."} + results = data.get("results", []) + if not results: + return {"error": "No location found for the given coordinates."} + + nearby_place_name = _fetch_google_nearby_place_name(lat, lon, api_key) + location_name = _extract_google_location_name(results, nearby_place_name=nearby_place_name) + # Convert Google schema to Nominatim-style for extractIsoCode - first_result = data.get("results", [])[0] + first_result = results[0] + address_result = next( + (result for result in results if "plus_code" not in result.get("types", [])), + first_result, + ) result_data = { "name": first_result.get("formatted_address"), - "address": _parse_google_address_components(first_result.get("address_components", [])) + "location_name": location_name, + "address": _parse_google_address_components(address_result.get("address_components", [])), } return extractIsoCode(user, result_data) except requests.exceptions.Timeout: diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index d1a9c4b0..5d0564c0 100644 --- a/backend/server/adventures/views/location_image_view.py +++ b/backend/server/adventures/views/location_image_view.py @@ -4,8 +4,11 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.throttling import UserRateThrottle from django.http import HttpResponse +from concurrent.futures import ThreadPoolExecutor, as_completed import ipaddress +import mimetypes import socket +from urllib.parse import urljoin from urllib.parse import urlparse from django.db.models import Q from django.core.files.base import ContentFile @@ -17,6 +20,7 @@ import requests from adventures.permissions import ContentImagePermission import logging +import uuid logger = logging.getLogger(__name__) @@ -67,6 +71,144 @@ def _is_safe_url(image_url): return True, parsed +def download_remote_image(image_url): + safe, result = _is_safe_url(image_url) + if not safe: + raise ValueError(result) + + headers = {'User-Agent': 'AdventureLog/1.0 (Image Import)'} + max_redirects = 3 + current_url = image_url + + response = None + for _ in range(max_redirects + 1): + response = requests.get( + current_url, + timeout=10, + headers=headers, + stream=True, + allow_redirects=False, + ) + + if not response.is_redirect: + break + + redirect_url = response.headers.get('Location', '') + if not redirect_url: + raise ValueError('Redirect with missing Location header') + + # Handle relative redirects safely. + redirect_url = urljoin(current_url, redirect_url) + + safe, result = _is_safe_url(redirect_url) + if not safe: + raise ValueError(f'Redirect blocked: {result}') + + current_url = redirect_url + else: + raise ValueError('Too many redirects') + + if response is None: + raise ValueError('Failed to fetch image') + + response.raise_for_status() + + content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower() + if not content_type.startswith('image/'): + raise ValueError('URL does not point to an image') + + content_length = response.headers.get('Content-Length') + if content_length and int(content_length) > 20 * 1024 * 1024: + raise ValueError('Image too large (max 20MB)') + + ext = mimetypes.guess_extension(content_type) or '.jpg' + if ext == '.jpe': + ext = '.jpg' + + return { + 'filename': f"remote_{uuid.uuid4().hex}{ext}", + 'content': response.content, + 'content_type': content_type, + 'source_url': image_url, + } + + +def import_remote_images_for_object(content_object, urls, owner=None, max_workers=5): + """Download remote URLs and attach them as ContentImage records for a content object.""" + content_type = ContentType.objects.get_for_model(content_object.__class__) + object_id = str(content_object.id) + image_owner = owner or getattr(content_object, 'user', None) + + downloaded_results = [] + worker_count = max(1, min(max_workers, len(urls))) + + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = { + executor.submit(download_remote_image, image_url): (index, image_url) + for index, image_url in enumerate(urls) + } + + for future in as_completed(futures): + index, image_url = futures[future] + try: + file_data = future.result() + downloaded_results.append((index, image_url, file_data, None)) + except Exception as exc: + downloaded_results.append((index, image_url, None, str(exc))) + + downloaded_results.sort(key=lambda item: item[0]) + + existing_image_count = ContentImage.objects.filter( + content_type=content_type, + object_id=object_id, + ).count() + set_primary_next = existing_image_count == 0 + + created_images = [] + results = [] + failed = [] + + for _, image_url, file_data, error_message in downloaded_results: + if error_message: + failure = { + 'url': image_url, + 'error': error_message, + } + results.append({ + **failure, + 'status': 'failed', + }) + failed.append(failure) + continue + + image_file = ContentFile(file_data['content'], name=file_data['filename']) + image = ContentImage.objects.create( + user=image_owner, + image=image_file, + content_type=content_type, + object_id=object_id, + is_primary=set_primary_next, + ) + if set_primary_next: + set_primary_next = False + + created_images.append(image) + results.append({ + 'url': image_url, + 'status': 'created', + 'id': str(image.id), + }) + + return { + 'created_images': created_images, + 'results': results, + 'created_count': len(created_images), + 'requested_count': len(urls), + 'failed_count': len(failed), + 'failed': failed, + } + + class ContentImageViewSet(viewsets.ModelViewSet): serializer_class = ContentImageSerializer permission_classes = [ContentImagePermission] @@ -192,69 +334,12 @@ def fetch_from_url(self, request): status=status.HTTP_400_BAD_REQUEST ) - # Validate the initial URL (scheme, port, SSRF check on all resolved IPs) - safe, result = _is_safe_url(image_url) - if not safe: - return Response({"error": result}, status=status.HTTP_400_BAD_REQUEST) - try: - headers = {'User-Agent': 'AdventureLog/1.0 (Image Proxy)'} - max_redirects = 3 - current_url = image_url - - for _ in range(max_redirects + 1): - response = requests.get( - current_url, - timeout=10, - headers=headers, - stream=True, - allow_redirects=False, - ) - - if not response.is_redirect: - break - - # Re-validate every redirect destination before following - redirect_url = response.headers.get('Location', '') - if not redirect_url: - return Response( - {"error": "Redirect with missing Location header"}, - status=status.HTTP_502_BAD_GATEWAY, - ) - - safe, result = _is_safe_url(redirect_url) - if not safe: - return Response( - {"error": f"Redirect blocked: {result}"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - current_url = redirect_url - else: - return Response( - {"error": "Too many redirects"}, - status=status.HTTP_400_BAD_REQUEST, - ) - - response.raise_for_status() - - content_type = response.headers.get('Content-Type', '') - if not content_type.startswith('image/'): - return Response( - {"error": "URL does not point to an image"}, - status=status.HTTP_400_BAD_REQUEST - ) - - content_length = response.headers.get('Content-Length') - if content_length and int(content_length) > 20 * 1024 * 1024: - return Response( - {"error": "Image too large (max 20MB)"}, - status=status.HTTP_400_BAD_REQUEST - ) - - image_data = response.content - - return HttpResponse(image_data, content_type=content_type, status=200) + image_data = download_remote_image(str(image_url).strip()) + return HttpResponse(image_data['content'], content_type=image_data['content_type'], status=200) + + except ValueError as exc: + return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except requests.exceptions.Timeout: logger.error("Timeout fetching image from URL %s", image_url) @@ -269,6 +354,64 @@ def fetch_from_url(self, request): status=status.HTTP_502_BAD_GATEWAY ) + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def import_from_urls(self, request): + content_type_name = request.data.get('content_type') + object_id = request.data.get('object_id') + urls = request.data.get('urls') + + if not isinstance(urls, list) or not urls: + return Response({"error": "urls must be a non-empty array"}, status=status.HTTP_400_BAD_REQUEST) + + urls = [str(url).strip() for url in urls if str(url).strip()] + if not urls: + return Response({"error": "No valid URLs provided"}, status=status.HTTP_400_BAD_REQUEST) + + if len(urls) > 10: + return Response({"error": "Maximum 10 URLs per request"}, status=status.HTTP_400_BAD_REQUEST) + + content_object = self._get_and_validate_content_object(content_type_name, object_id) + if isinstance(content_object, Response): + return content_object + + owner = getattr(content_object, 'user', request.user) + + import_summary = import_remote_images_for_object( + content_object, + urls, + owner=owner, + max_workers=min(5, len(urls)), + ) + + created_images = import_summary['created_images'] + results = import_summary['results'] + + if not created_images: + return Response( + { + 'error': 'No images could be imported', + 'results': results, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + serialized = ContentImageSerializer(created_images, many=True, context={'request': request}) + response_status = ( + status.HTTP_201_CREATED + if import_summary['created_count'] == import_summary['requested_count'] + else status.HTTP_200_OK + ) + + return Response( + { + 'created': serialized.data, + 'results': results, + 'created_count': import_summary['created_count'], + 'requested_count': import_summary['requested_count'], + }, + status=response_status, + ) + def create(self, request, *args, **kwargs): # Get content type and object ID from request content_type_name = request.data.get('content_type') diff --git a/backend/server/adventures/views/location_view.py b/backend/server/adventures/views/location_view.py index c9630e18..22337d87 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -1,4 +1,5 @@ import logging +from urllib.parse import urlparse from django.utils import timezone from django.db import transaction from django.core.exceptions import PermissionDenied @@ -14,6 +15,9 @@ from adventures.permissions import IsOwnerOrSharedWithFullAccess from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer from adventures.utils import pagination +from adventures.geocoding import get_place_details, reverse_geocode +from worldtravel.models import City, Country, Region +from .location_image_view import import_remote_images_for_object logger = logging.getLogger(__name__) @@ -158,6 +162,122 @@ def destroy(self, request, *args, **kwargs): # ==================== CUSTOM ACTIONS ==================== + @action(detail=False, methods=['post'], url_path='quick-add') + @transaction.atomic + def quick_add(self, request): + """Create a location from lightweight map/place input in one server-side call.""" + payload = request.data if isinstance(request.data, dict) else {} + + name = str(payload.get('name') or '').strip() + if not name: + return Response({"error": "name is required"}, status=status.HTTP_400_BAD_REQUEST) + + latitude = self._coerce_coordinate(payload.get('latitude'), -90, 90) + longitude = self._coerce_coordinate(payload.get('longitude'), -180, 180) + if latitude is None or longitude is None: + return Response( + {"error": "Valid latitude and longitude are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + collection = self._resolve_quick_add_collection(payload.get('collection_id')) + if isinstance(collection, Response): + return collection + + place_id = str(payload.get('place_id') or '').strip() or None + reverse_data = {} + details = {} + + try: + reverse_result = reverse_geocode(latitude, longitude, request.user) + if isinstance(reverse_result, dict) and 'error' not in reverse_result: + reverse_data = reverse_result + except Exception: + reverse_data = {} + + if place_id: + details_result = get_place_details(place_id, fallback_query=name) + if isinstance(details_result, dict): + if 'error' not in details_result or details_result.get('description'): + details = details_result + + rating = self._coerce_float(payload.get('rating')) + if rating is None: + rating = self._coerce_float(details.get('rating')) + + review_count = self._coerce_int(payload.get('review_count')) + if review_count is None: + review_count = self._coerce_int(details.get('review_count')) + + website = self._clean_url(details.get('website')) or self._clean_url(payload.get('website')) + maps_url = self._clean_url(details.get('google_maps_url')) or self._clean_url( + payload.get('google_maps_url') + ) + link = self._clean_url(payload.get('link')) or website or maps_url + + phone_number = str(details.get('phone_number') or payload.get('phone_number') or '').strip() or None + + location_label = ( + str(payload.get('location') or '').strip() + or str(reverse_data.get('display_name') or '').strip() + or str(details.get('formatted_address') or '').strip() + or None + ) + + description = self._build_quick_add_description( + base_description=payload.get('description'), + detailed_description=details.get('description'), + ) + + category_payload = self._normalize_quick_add_category(payload.get('category')) + if isinstance(category_payload, Response): + return category_payload + + serializer_payload = { + 'name': name, + 'location': location_label, + 'latitude': latitude, + 'longitude': longitude, + 'rating': rating, + 'description': description, + 'link': link, + 'tags': self._sanitize_tags(payload.get('types') or payload.get('tags')), + 'is_public': self._coerce_bool(payload.get('is_public'), default=False), + } + + if category_payload: + serializer_payload['category'] = category_payload + + if collection: + serializer_payload['collections'] = [str(collection.id)] + + serializer = self.get_serializer(data=serializer_payload) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + location = serializer.instance + self._apply_reverse_geocode_metadata(location, reverse_data, location_label) + + photo_urls = self._sanitize_photo_urls(payload.get('photos')) + image_import_summary = None + if photo_urls: + image_import_summary = import_remote_images_for_object( + location, + photo_urls, + owner=location.user, + max_workers=min(5, len(photo_urls)), + ) + + response_data = self.get_serializer(location).data + if image_import_summary and image_import_summary.get('failed'): + response_data['quick_add_image_import'] = { + 'created_count': image_import_summary['created_count'], + 'failed_count': image_import_summary['failed_count'], + 'failed': image_import_summary['failed'], + } + + return Response(response_data, status=status.HTTP_201_CREATED) + @action(detail=False, methods=['get']) def filtered(self, request): """Filter locations by category types and visit status.""" @@ -460,6 +580,195 @@ def _validate_collection_permissions(self, collections): f"You don't have permission to add location to collection '{collection.name}'" ) + def _resolve_quick_add_collection(self, collection_id): + if not collection_id: + return None + + try: + collection = Collection.objects.get(id=collection_id) + except Collection.DoesNotExist: + return Response( + {"error": "Collection not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + try: + self._validate_collection_permissions([collection]) + except PermissionDenied as exc: + return Response({"error": str(exc)}, status=status.HTTP_403_FORBIDDEN) + + return collection + + def _coerce_coordinate(self, value, min_value, max_value): + try: + number = round(float(value), 6) + except (TypeError, ValueError): + return None + + if number < min_value or number > max_value: + return None + + return number + + def _coerce_float(self, value): + try: + return float(value) + except (TypeError, ValueError): + return None + + def _coerce_int(self, value): + try: + return int(value) + except (TypeError, ValueError): + return None + + def _coerce_bool(self, value, default=False): + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {'true', '1', 'yes', 'on'}: + return True + if normalized in {'false', '0', 'no', 'off'}: + return False + return default + + def _clean_url(self, value): + if not isinstance(value, str): + return None + + normalized = value.strip() + if not normalized: + return None + + parsed = urlparse(normalized) + if parsed.scheme in {'http', 'https'} and parsed.netloc: + return normalized + + return None + + def _sanitize_tags(self, raw_tags): + if not isinstance(raw_tags, list): + return [] + + tags = [] + for item in raw_tags: + if not isinstance(item, str): + continue + + value = item.strip() + if not value or value in tags: + continue + + tags.append(value) + if len(tags) >= 8: + break + + return tags + + def _sanitize_photo_urls(self, raw_urls): + if not isinstance(raw_urls, list): + return [] + + cleaned = [] + for value in raw_urls: + url = self._clean_url(value) + if not url or url in cleaned: + continue + cleaned.append(url) + if len(cleaned) >= 5: + break + + return cleaned + + def _normalize_quick_add_category(self, raw_category): + if not raw_category: + return None + + if isinstance(raw_category, dict): + category_id = raw_category.get('id') + name = str(raw_category.get('name') or '').strip().lower() + display_name = str(raw_category.get('display_name') or '').strip() + icon = str(raw_category.get('icon') or '').strip() or '🌍' + elif isinstance(raw_category, str): + category_id = raw_category.strip() + name = '' + display_name = '' + icon = '🌍' + else: + return Response( + {"error": "category must be an object or string"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + category = None + if category_id: + category = Category.objects.filter(id=category_id, user=self.request.user).first() + if not category: + return Response( + {"error": "Category not found or inaccessible"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if category: + return { + 'name': category.name, + 'display_name': category.display_name, + 'icon': category.icon, + } + + if not name: + return None + + return { + 'name': name, + 'display_name': display_name or name, + 'icon': icon, + } + + def _build_quick_add_description( + self, + base_description, + detailed_description, + ): + description = str(detailed_description or '').strip() or str(base_description or '').strip() + + return description or None + + def _apply_reverse_geocode_metadata(self, location, reverse_data, fallback_location): + if not isinstance(reverse_data, dict): + reverse_data = {} + + updated_fields = [] + + region_id = reverse_data.get('region_id') + if region_id: + region = Region.objects.filter(id=region_id).first() + if region and location.region_id != region.id: + location.region = region + updated_fields.append('region') + + city_id = reverse_data.get('city_id') + if city_id: + city = City.objects.filter(id=city_id).first() + if city and location.city_id != city.id: + location.city = city + updated_fields.append('city') + + country_id = reverse_data.get('country_id') + if country_id: + country = Country.objects.filter(country_code=country_id).first() + if country and location.country_id != country.id: + location.country = country + updated_fields.append('country') + + if fallback_location and not location.location: + location.location = fallback_location + updated_fields.append('location') + + if updated_fields: + location.save(update_fields=updated_fields, _skip_geocode=True) + def _apply_visit_filtering(self, queryset, request): """Apply visit status filtering to queryset.""" is_visited_param = request.query_params.get('is_visited') diff --git a/backend/server/adventures/views/reverse_geocode_view.py b/backend/server/adventures/views/reverse_geocode_view.py index b0635300..d9fa55d1 100644 --- a/backend/server/adventures/views/reverse_geocode_view.py +++ b/backend/server/adventures/views/reverse_geocode_view.py @@ -7,7 +7,7 @@ from adventures.serializers import LocationSerializer from adventures.geocoding import reverse_geocode from django.conf import settings -from adventures.geocoding import search_google, search_osm +from adventures.geocoding import search_google, search_osm, get_place_details class ReverseGeocodeViewSet(viewsets.ViewSet): permission_classes = [IsAuthenticated] @@ -131,4 +131,18 @@ def mark_visited_region(self, request): "regions": new_regions, "new_cities": new_city_count, "cities": new_cities - }) \ No newline at end of file + }) + + @action(detail=False, methods=['get']) + def place_details(self, request): + place_id = request.query_params.get('place_id', '').strip() + if not place_id: + return Response({"error": "place_id parameter is required"}, status=400) + + name = request.query_params.get('name', '') + language = request.query_params.get('language', 'en') + + details = get_place_details(place_id, fallback_query=name, language=language) + if 'error' in details and not details.get('description'): + return Response(details, status=502) + return Response(details) \ No newline at end of file diff --git a/frontend/src/lib/components/locations/LocationDetails.svelte b/frontend/src/lib/components/locations/LocationDetails.svelte index bb1d5895..1f4c55f4 100755 --- a/frontend/src/lib/components/locations/LocationDetails.svelte +++ b/frontend/src/lib/components/locations/LocationDetails.svelte @@ -6,7 +6,8 @@ import MoneyInput from '../shared/MoneyInput.svelte'; import MarkdownEditor from '../MarkdownEditor.svelte'; import TagComplete from '../TagComplete.svelte'; - import { DEFAULT_CURRENCY, normalizeMoneyPayload, toMoneyValue } from '$lib/money'; + import { DEFAULT_CURRENCY, toMoneyValue } from '$lib/money'; + import { saveLocation } from '$lib/location-save'; import { addToast } from '$lib/toasts'; import type { Category, Collection, Location, MoneyValue, User } from '$lib/types'; import MapIcon from '~icons/mdi/map'; @@ -67,6 +68,14 @@ let isGeneratingDesc = false; let ownerUser: User | null = null; + function toFiniteNumber(value: unknown): number | null { + if (value === null || value === undefined) { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + export let initialLocation: any = null; export let currentUser: any = null; export let editingLocation: any = null; @@ -84,21 +93,25 @@ location.price_currency = defaultCurrency; } } - $: initialSelection = - initialLocation && initialLocation.latitude && initialLocation.longitude - ? { - name: initialLocation.name || '', - lat: Number(initialLocation.latitude), - lng: Number(initialLocation.longitude), - location: initialLocation.location || '' - } - : null; + $: { + const lat = toFiniteNumber(initialLocation?.latitude); + const lng = toFiniteNumber(initialLocation?.longitude); + initialSelection = + initialLocation && lat !== null && lng !== null + ? { + name: initialLocation.name || '', + lat, + lng, + location: initialLocation.location || '' + } + : null; + } function handleLocationUpdate( event: CustomEvent<{ name?: string; lat: number; lng: number; location: string }> ) { const { name, lat, lng, location: displayName } = event.detail; - if (!location.name && name) location.name = name; + if (name) location.name = name; location.latitude = lat; location.longitude = lng; location.location = displayName; @@ -139,83 +152,31 @@ return; } - if (location.latitude !== null && typeof location.latitude === 'number') { - location.latitude = parseFloat(location.latitude.toFixed(6)); - } - if (location.longitude !== null && typeof location.longitude === 'number') { - location.longitude = parseFloat(location.longitude.toFixed(6)); - } - if (collection && collection.id) { - location.collections = [collection.id]; - } - - let payload: any = { ...location }; - - // Clean up link: empty/whitespace → null, invalid URL → null - if (!payload.link || !payload.link.trim()) { - payload.link = null; - } else { - try { - new URL(payload.link); - } catch { - // Not a valid URL — clear it so Django doesn't reject it - payload.link = null; - } - } - if (!payload.description || !payload.description.trim()) { - payload.description = null; - } - - if (location.price === null) { - payload.price = null; - payload.price_currency = null; - } else { - payload = normalizeMoneyPayload(payload, 'price', 'price_currency', defaultCurrency); - } - - let res: Response; - if (locationToEdit && locationToEdit.id) { - // Only include collections if explicitly set via a collection context; - // otherwise remove them from the PATCH payload to avoid triggering the - // m2m_changed signal which can override is_public. - if (!collection || !collection.id) { - delete payload.collections; - } - - res = await fetch(`/api/locations/${locationToEdit.id}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }); - } else { - res = await fetch(`/api/locations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) + try { + const savedLocation = await saveLocation({ + location, + locationToEdit, + collectionId: collection?.id || null, + defaultCurrency }); - } - - if (!res.ok) { - const errorData = await res.json().catch(() => ({})); - // Extract error message from Django field errors (e.g. {"link": ["Enter a valid URL."]}) - let errorMsg = errorData?.detail || errorData?.name?.[0] || ''; - if (!errorMsg) { - const fieldErrors = Object.entries(errorData) - .filter(([_, v]) => Array.isArray(v)) - .map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`) - .join('; '); - errorMsg = fieldErrors || 'Failed to save location'; - } - addToast('error', String(errorMsg)); + location = { + ...location, + ...savedLocation, + rating: + typeof savedLocation.rating === 'number' && !Number.isNaN(savedLocation.rating) + ? savedLocation.rating + : location.rating, + link: savedLocation.link || location.link || '', + description: savedLocation.description || location.description || '', + location: savedLocation.location || location.location || '', + tags: savedLocation.tags || location.tags || [], + collections: savedLocation.collections || location.collections || [] + }; + } catch (error) { + addToast('error', error instanceof Error ? error.message : 'Failed to save location'); return; } - location = await res.json(); - dispatch('save', { ...location }); @@ -226,9 +187,11 @@ } onMount(() => { - if (initialLocation && initialLocation.latitude && initialLocation.longitude) { - location.latitude = initialLocation.latitude; - location.longitude = initialLocation.longitude; + const lat = toFiniteNumber(initialLocation?.latitude); + const lng = toFiniteNumber(initialLocation?.longitude); + if (initialLocation && lat !== null && lng !== null) { + location.latitude = lat; + location.longitude = lng; if (!location.name) location.name = initialLocation.name || ''; if (initialLocation.location) location.location = initialLocation.location; } diff --git a/frontend/src/lib/components/locations/LocationModal.svelte b/frontend/src/lib/components/locations/LocationModal.svelte index 4694bc97..22ee9e37 100644 --- a/frontend/src/lib/components/locations/LocationModal.svelte +++ b/frontend/src/lib/components/locations/LocationModal.svelte @@ -19,6 +19,10 @@ let storedInitialVisitDate: string | null = initialVisitDate; let modal: HTMLDialogElement; + let googleMapsEnabled = false; + let isEditMode = false; + let pendingGooglePhotoUrls: string[] = []; + let importingGooglePhotos = false; // Whether a save/create occurred during this modal session let didSave = false; @@ -46,6 +50,105 @@ } ]; + function setStep(stepIndex: number) { + steps = steps.map((step, index) => ({ + ...step, + selected: index === stepIndex + })); + } + + function handleStepSelect(stepIndex: number) { + if (stepIndex === 0 && isEditMode) { + return; + } + if (steps[stepIndex]?.requires_id && !location.id) { + return; + } + setStep(stepIndex); + } + + function handleDetailsBack() { + if (isEditMode) { + close(); + return; + } + setStep(0); + } + + function applyQuickStartPrefill(prefill: any) { + if (!prefill) return; + + if (prefill.name) location.name = prefill.name; + if (prefill.location) location.location = prefill.location; + if (typeof prefill.latitude === 'number') location.latitude = prefill.latitude; + if (typeof prefill.longitude === 'number') location.longitude = prefill.longitude; + if (typeof prefill.rating === 'number') location.rating = prefill.rating; + if (!location.link && (prefill.website || prefill.google_maps_url)) { + location.link = prefill.website || prefill.google_maps_url; + } + if (!location.description && prefill.description) { + location.description = prefill.description; + } + if ((!location.tags || location.tags.length === 0) && Array.isArray(prefill.types)) { + location.tags = prefill.types.slice(0, 8); + } + if (prefill.selected_category && typeof prefill.selected_category === 'object') { + location.category = prefill.selected_category; + } + pendingGooglePhotoUrls = Array.isArray(prefill.photos) + ? prefill.photos.filter((url: unknown) => typeof url === 'string' && url.trim()).slice(0, 5) + : []; + } + + async function importPendingGoogleImages(locationId: string) { + if (!locationId || pendingGooglePhotoUrls.length === 0) return; + importingGooglePhotos = true; + + try { + const res = await fetch('/api/images/import_from_urls/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + content_type: 'location', + object_id: locationId, + urls: pendingGooglePhotoUrls + }) + }); + + if (!res.ok) { + addToast('warning', 'Location saved, but Google photos could not be imported'); + return; + } + + const data = await res.json(); + if (Array.isArray(data.created) && data.created.length > 0) { + const existingImages = Array.isArray(location.images) ? location.images : []; + const existingIds = new Set(existingImages.map((img: any) => img.id)); + const imported = data.created.filter((img: any) => !existingIds.has(img.id)); + location.images = [...existingImages, ...imported]; + } + + pendingGooglePhotoUrls = []; + } catch { + addToast('warning', 'Location saved, but Google photos import failed'); + } finally { + importingGooglePhotos = false; + } + } + + async function loadIntegrations() { + try { + const res = await fetch('/api/integrations/'); + if (!res.ok) return; + const integrations = await res.json(); + googleMapsEnabled = Boolean(integrations?.google_maps); + } catch { + googleMapsEnabled = false; + } + } + export let location: Location = { id: '', name: '', @@ -81,17 +184,17 @@ link: locationToEdit?.link || null, description: locationToEdit?.description || null, tags: locationToEdit?.tags || [], - rating: locationToEdit?.rating || NaN, + rating: locationToEdit?.rating ?? NaN, price: locationToEdit?.price ?? null, price_currency: locationToEdit?.price_currency ?? null, - is_public: locationToEdit?.is_public || false, - latitude: locationToEdit?.latitude || NaN, - longitude: locationToEdit?.longitude || NaN, + is_public: locationToEdit?.is_public ?? false, + latitude: locationToEdit?.latitude ?? NaN, + longitude: locationToEdit?.longitude ?? NaN, location: locationToEdit?.location || null, images: locationToEdit?.images || [], user: locationToEdit?.user || null, visits: locationToEdit?.visits || [], - is_visited: locationToEdit?.is_visited || false, + is_visited: locationToEdit?.is_visited ?? false, collections: locationToEdit?.collections || [], category: locationToEdit?.category || { id: '', @@ -104,23 +207,25 @@ attachments: locationToEdit?.attachments || [] }; - onMount(async () => { + onMount(() => { modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal.showModal(); + isEditMode = Boolean(locationToEdit?.id); + // Skip the quick start step if editing an existing location - if (!locationToEdit) { - steps[0].selected = true; - steps[1].selected = false; + if (!isEditMode) { + setStep(0); } else { - steps[0].selected = false; - steps[1].selected = true; + setStep(1); } + if (initialLatLng) { location.latitude = initialLatLng.lat; location.longitude = initialLatLng.lng; - steps[1].selected = true; - steps[0].selected = false; + setStep(1); } + + void loadIntegrations(); }); function close() { @@ -206,7 +311,7 @@ > @@ -216,14 +321,11 @@ ? 'bg-primary text-primary-content' : 'bg-base-200'} {step.requires_id && !location.id ? 'opacity-50 cursor-not-allowed' + : ''} {index === 0 && isEditMode + ? 'opacity-50 cursor-not-allowed' : 'hover:bg-primary/80 cursor-pointer'} transition-colors" - on:click={() => { - // Reset all steps - steps.forEach((s) => (s.selected = false)); - // Select clicked step - steps[index].selected = true; - }} - disabled={step.requires_id && !location.id} + on:click={() => handleStepSelect(index)} + disabled={(step.requires_id && !location.id) || (index === 0 && isEditMode)} > - {#if steps[0].selected} + {#if steps[0].selected && !isEditMode} { - location.name = e.detail.name; - location.location = e.detail.location; - location.latitude = e.detail.latitude; - location.longitude = e.detail.longitude; - steps[0].selected = false; - steps[1].selected = true; + googleEnabled={googleMapsEnabled} + collectionId={collection?.id || null} + on:addDetails={(e) => { + applyQuickStartPrefill(e.detail.prefill); + setStep(1); }} - on:cancel={() => close()} - on:next={() => { - steps[0].selected = false; - steps[1].selected = true; + on:manual={() => { + setStep(1); }} + on:quickAdded={(e) => { + location = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + close(); + }} + on:quickAddedEdit={(e) => { + location = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + setStep(1); + }} + on:quickAddedDone={(e) => { + location = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + close(); + }} + on:cancel={() => close()} /> {/if} {#if steps[1].selected} @@ -300,55 +417,49 @@ initialLocation={location} {collection} bind:editingLocation={location} - on:back={() => { - steps[1].selected = false; - steps[0].selected = true; - }} - on:save={(e) => { - location.name = e.detail.name; - location.category = e.detail.category; - location.rating = e.detail.rating; - location.is_public = e.detail.is_public; - location.link = e.detail.link; - location.description = e.detail.description; - location.latitude = e.detail.latitude; - location.longitude = e.detail.longitude; - location.location = e.detail.location; - location.tags = e.detail.tags; - location.user = e.detail.user; - location.id = e.detail.id; - location.price = e.detail.price; - location.price_currency = e.detail.price_currency; + on:back={handleDetailsBack} + on:save={async (e) => { + location = { + ...location, + ...e.detail, + tags: e.detail.tags || location.tags || [], + images: e.detail.images || location.images || [], + attachments: e.detail.attachments || location.attachments || [], + trails: e.detail.trails || location.trails || [], + visits: e.detail.visits || location.visits || [] + }; // Mark that a save occurred so close() will notify parent didSave = true; - steps[1].selected = false; if (location.id) { - steps[2].selected = true; + setStep(2); + if (pendingGooglePhotoUrls.length > 0) { + void importPendingGoogleImages(location.id); + } } else { // Stay on details if save failed (no ID returned) - steps[1].selected = true; + setStep(1); } }} /> {/if} {#if steps[2].selected} + {#if importingGooglePhotos} +
+ + Importing Google photos in the background. They will appear here shortly. +
+ {/if} { - steps[2].selected = false; - steps[1].selected = true; - }} + on:back={() => setStep(1)} itemId={location.id} - on:next={() => { - steps[2].selected = false; - steps[3].selected = true; - }} + on:next={() => setStep(3)} measurementSystem={user?.measurement_system || 'metric'} /> {/if} @@ -357,10 +468,7 @@ bind:visits={location.visits} bind:trails={location.trails} objectId={location.id} - on:back={() => { - steps[3].selected = false; - steps[2].selected = true; - }} + on:back={() => setStep(2)} on:close={() => close()} measurementSystem={user?.measurement_system || 'metric'} {collection} diff --git a/frontend/src/lib/components/locations/LocationQuickStart.svelte b/frontend/src/lib/components/locations/LocationQuickStart.svelte index ac11484e..6b955906 100644 --- a/frontend/src/lib/components/locations/LocationQuickStart.svelte +++ b/frontend/src/lib/components/locations/LocationQuickStart.svelte @@ -3,38 +3,229 @@ import { MapLibre, Marker, MapEvents } from 'svelte-maplibre'; import { t } from 'svelte-i18n'; import { getBasemapUrl } from '$lib'; + import { addToast } from '$lib/toasts'; + import CategoryDropdown from '../CategoryDropdown.svelte'; + import type { Category } from '$lib/types'; - // Icons import SearchIcon from '~icons/mdi/magnify'; import LocationIcon from '~icons/mdi/crosshairs-gps'; import MapIcon from '~icons/mdi/map'; import CheckIcon from '~icons/mdi/check'; import ClearIcon from '~icons/mdi/close'; import PinIcon from '~icons/mdi/map-marker'; + import StarIcon from '~icons/mdi/star'; + import LightningIcon from '~icons/mdi/lightning-bolt'; + import PencilIcon from '~icons/mdi/pencil'; + + type SelectedPlace = { + id: string; + name: string; + lat: number; + lng: number; + location: string; + type?: string; + category?: string; + types?: string[]; + rating?: number | null; + review_count?: number | null; + photos?: string[]; + description?: string | null; + website?: string | null; + phone_number?: string | null; + place_id?: string | null; + google_maps_url?: string | null; + powered_by?: string; + }; + + type LocationData = { + city?: { name: string; id: string; visited: boolean }; + region?: { name: string; id: string; visited: boolean }; + country?: { name: string; country_code: string; visited: boolean }; + display_name?: string; + location_name?: string; + }; const dispatch = createEventDispatcher(); + export let googleEnabled = false; + export let collectionId: string | null = null; + let searchQuery = ''; - let searchResults: any[] = []; - let selectedLocation: any = null; - let mapCenter: [number, number] = [-74.5, 40]; // Default center + let searchResults: SelectedPlace[] = []; + let selectedLocation: SelectedPlace | null = null; + let mapCenter: [number, number] = [-74.5, 40]; let mapZoom = 2; let isSearching = false; let isReverseGeocoding = false; + let isEnrichingDescription = false; + let isQuickAdding = false; + let quickAddedLocation: any = null; let searchTimeout: ReturnType; let mapComponent: any; let selectedMarker: { lng: number; lat: number } | null = null; + let locationData: LocationData | null = null; + let selectedQuickAddCategory: Category | null = null; + const placeDetailsCache = new Map(); + + function toPlaceResult(result: any): SelectedPlace { + return { + id: result.place_id || `${result.name || 'place'}-${result.lat}-${result.lon}`, + name: result.name, + lat: parseFloat(result.lat), + lng: parseFloat(result.lon), + location: result.display_name, + type: result.type, + category: result.category, + types: result.types || [], + rating: result.rating ?? null, + review_count: result.review_count ?? null, + photos: result.photos || [], + description: result.description || null, + website: result.website || null, + phone_number: result.phone_number || null, + place_id: result.place_id || null, + google_maps_url: result.google_maps_url || null, + powered_by: result.powered_by + }; + } - // Enhanced location data from reverse geocoding - let locationData: { - city?: { name: string; id: string; visited: boolean }; - region?: { name: string; id: string; visited: boolean }; - country?: { name: string; country_code: string; visited: boolean }; - display_name?: string; - location_name?: string; - } | null = null; + function pickBestNearbyResult( + results: SelectedPlace[], + lat: number, + lng: number, + preferredName?: string + ): SelectedPlace | null { + if (!results.length) { + return null; + } + + const normalizedPreferredName = (preferredName || '').trim().toLowerCase(); + const scored = results + .filter((item) => Number.isFinite(item.lat) && Number.isFinite(item.lng)) + .map((item) => { + const dLat = item.lat - lat; + const dLng = item.lng - lng; + const distanceScore = dLat * dLat + dLng * dLng; + const nameScore = + normalizedPreferredName && item.name?.trim().toLowerCase() === normalizedPreferredName ? -1 : 0; + const placeScore = item.place_id ? -0.5 : 0; + return { + item, + score: distanceScore + nameScore + placeScore + }; + }); + + if (!scored.length) { + return results[0]; + } + + scored.sort((a, b) => a.score - b.score); + return scored[0].item; + } + + async function enrichFromResolvedName(lat: number, lng: number, resolvedName: string) { + const query = resolvedName.trim(); + if (!query) { + return; + } + + try { + const response = await fetch( + `/api/reverse-geocode/search/?query=${encodeURIComponent(query)}` + ); + if (!response.ok) { + return; + } + + const rawResults = await response.json(); + const mappedResults = Array.isArray(rawResults) + ? rawResults.map(toPlaceResult) + : []; + const bestMatch = pickBestNearbyResult(mappedResults, lat, lng, query); + if (!bestMatch || !selectedLocation) { + return; + } + + selectedLocation = { + ...selectedLocation, + ...bestMatch, + lat, + lng, + name: bestMatch.name || selectedLocation.name, + location: selectedLocation.location || bestMatch.location + }; + searchQuery = selectedLocation.name; + } catch (error) { + console.error('Resolved name enrichment error:', error); + } + } + + function needsDescriptionEnrichment(place: SelectedPlace | null) { + if (!place?.place_id) { + return false; + } + + const text = (place.description || '').trim(); + return text.length < 220; + } + + async function fetchPlaceDetails(placeId: string, name: string) { + if (placeDetailsCache.has(placeId)) { + return placeDetailsCache.get(placeId); + } + + const response = await fetch( + `/api/reverse-geocode/place_details/?place_id=${encodeURIComponent(placeId)}&name=${encodeURIComponent(name || '')}` + ); + if (!response.ok) { + throw new Error('Unable to fetch place details'); + } + + const details = await response.json(); + placeDetailsCache.set(placeId, details); + return details; + } + + async function enrichSelectedLocationDescription(force = false) { + if (!selectedLocation?.place_id) { + return; + } + + const placeId = selectedLocation.place_id; + if (!placeId || (!force && !needsDescriptionEnrichment(selectedLocation))) { + return; + } + + isEnrichingDescription = true; + try { + const details = await fetchPlaceDetails(placeId, selectedLocation.name || ''); + + if (!selectedLocation || selectedLocation.place_id !== placeId) { + return; + } + + selectedLocation = { + ...selectedLocation, + name: details.name || selectedLocation.name, + location: details.formatted_address || selectedLocation.location, + types: + Array.isArray(details.types) && details.types.length > 0 + ? details.types + : selectedLocation.types, + rating: details.rating ?? selectedLocation.rating ?? null, + review_count: details.review_count ?? selectedLocation.review_count ?? null, + description: details.description || selectedLocation.description || null, + website: details.website || selectedLocation.website || null, + phone_number: details.phone_number || selectedLocation.phone_number || null, + google_maps_url: details.google_maps_url || selectedLocation.google_maps_url || null + }; + } catch (error) { + console.error('Place details enrichment error:', error); + } finally { + isEnrichingDescription = false; + } + } - // Search for locations using your custom API async function searchLocations(query: string) { if (!query.trim() || query.length < 3) { searchResults = []; @@ -47,18 +238,7 @@ `/api/reverse-geocode/search/?query=${encodeURIComponent(query)}` ); const results = await response.json(); - - searchResults = results.map((result: any) => ({ - id: result.name + result.lat + result.lon, // Create a unique ID - name: result.name, - lat: parseFloat(result.lat), - lng: parseFloat(result.lon), - type: result.type, - category: result.category, - location: result.display_name, - importance: result.importance, - powered_by: result.powered_by - })); + searchResults = Array.isArray(results) ? results.map(toPlaceResult) : []; } catch (error) { console.error('Search error:', error); searchResults = []; @@ -67,7 +247,6 @@ } } - // Debounced search function handleSearchInput() { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { @@ -75,70 +254,62 @@ }, 300); } - // Select a location from search results - async function selectSearchResult(location: any) { + async function selectSearchResult(location: SelectedPlace) { selectedLocation = location; selectedMarker = { lng: location.lng, lat: location.lat }; mapCenter = [location.lng, location.lat]; mapZoom = 14; searchResults = []; searchQuery = location.name; - - // Perform detailed reverse geocoding await performDetailedReverseGeocode(location.lat, location.lng); } - // Handle map click to place marker async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) { selectedMarker = { lng: e.detail.lngLat.lng, lat: e.detail.lngLat.lat }; - - // Reverse geocode to get location name and detailed data await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat); } - // Reverse geocode coordinates to get location name using your API async function reverseGeocode(lng: number, lat: number) { isReverseGeocoding = true; try { - // Using a coordinate-based search query for reverse geocoding const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`); const results = await response.json(); - if (results && results.length > 0) { - const result = results[0]; + if (Array.isArray(results) && results.length > 0) { selectedLocation = { - name: result.name, - lat: lat, - lng: lng, - location: result.display_name, - type: result.type, - category: result.category + ...toPlaceResult(results[0]), + lat, + lng }; - searchQuery = result.name; + searchQuery = selectedLocation.name; } else { - // Fallback if no results from API selectedLocation = { + id: `manual-${lat}-${lng}`, name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`, - lat: lat, - lng: lng, - location: `${lat.toFixed(4)}, ${lng.toFixed(4)}` + lat, + lng, + location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`, + types: [], + photos: [] }; searchQuery = selectedLocation.name; } - // Perform detailed reverse geocoding await performDetailedReverseGeocode(lat, lng); } catch (error) { console.error('Reverse geocoding error:', error); selectedLocation = { + id: `manual-${lat}-${lng}`, name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`, - lat: lat, - lng: lng, - location: `${lat.toFixed(4)}, ${lng.toFixed(4)}` + lat, + lng, + location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`, + types: [], + photos: [] }; searchQuery = selectedLocation.name; locationData = null; @@ -147,7 +318,6 @@ } } - // Perform detailed reverse geocoding to get city, region, country data async function performDetailedReverseGeocode(lat: number, lng: number) { try { const response = await fetch( @@ -156,7 +326,6 @@ if (response.ok) { const data = await response.json(); - locationData = { city: data.city ? { @@ -176,15 +345,33 @@ ? { name: data.country, country_code: data.country_id, - visited: false // You might want to check this from your backend + visited: false } : undefined, display_name: data.display_name, location_name: data.location_name }; - selectedLocation.location = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`; + + if (selectedLocation) { + const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at '); + const shouldAutoEnrichQuickAdd = isCoordinatePlaceholder || !selectedLocation.place_id; + const resolvedLocationName = (data.location_name || '').trim(); + const resolvedDisplayName = (data.display_name || '').trim(); + + selectedLocation = { + ...selectedLocation, + name: + resolvedLocationName || + (isCoordinatePlaceholder && resolvedDisplayName ? resolvedDisplayName : selectedLocation.name), + location: resolvedDisplayName || `${lat.toFixed(4)}, ${lng.toFixed(4)}` + }; + searchQuery = selectedLocation.name; + + if (shouldAutoEnrichQuickAdd && resolvedLocationName) { + await enrichFromResolvedName(lat, lng, resolvedLocationName); + } + } } else { - console.warn('Detailed reverse geocoding failed:', response.status); locationData = null; } } catch (error) { @@ -193,7 +380,18 @@ } } - // Use current location + async function ensureAdventureLogFormattedLocation() { + if (!selectedMarker) { + return; + } + + if (locationData?.display_name?.trim()) { + return; + } + + await performDetailedReverseGeocode(selectedMarker.lat, selectedMarker.lng); + } + function useCurrentLocation() { if ('geolocation' in navigator) { navigator.geolocation.getCurrentPosition( @@ -212,39 +410,119 @@ } } - // Continue with selected location - function continueWithLocation() { - if (selectedLocation && selectedMarker) { - dispatch('locationSelected', { - name: selectedLocation.name, - latitude: selectedMarker.lat, - longitude: selectedMarker.lng, - location: selectedLocation.location, - type: selectedLocation.type, - category: selectedLocation.category, - // Include the enhanced geographical data - city: locationData?.city, - region: locationData?.region, - country: locationData?.country, - display_name: locationData?.display_name, - location_name: locationData?.location_name - }); - } else { - dispatch('next'); - } - } - - // Clear selection function clearSelection() { selectedLocation = null; selectedMarker = null; locationData = null; searchQuery = ''; searchResults = []; + quickAddedLocation = null; + selectedQuickAddCategory = null; mapCenter = [-74.5, 40]; mapZoom = 2; } + function buildPrefillPayload() { + if (!selectedLocation || !selectedMarker) { + return null; + } + + const formattedLocation = + locationData?.display_name?.trim() || selectedLocation.location?.trim() || ''; + + return { + name: selectedLocation.name, + latitude: selectedMarker.lat, + longitude: selectedMarker.lng, + location: formattedLocation, + type: selectedLocation.type, + category: selectedLocation.category, + city: locationData?.city, + region: locationData?.region, + country: locationData?.country, + display_name: locationData?.display_name, + location_name: locationData?.location_name, + rating: selectedLocation.rating ?? null, + review_count: selectedLocation.review_count ?? null, + photos: selectedLocation.photos || [], + description: selectedLocation.description || null, + website: selectedLocation.website || null, + phone_number: selectedLocation.phone_number || null, + place_id: selectedLocation.place_id || null, + google_maps_url: selectedLocation.google_maps_url || null, + types: selectedLocation.types || [], + selected_category: selectedQuickAddCategory + }; + } + + async function continueWithDetails() { + await ensureAdventureLogFormattedLocation(); + + if (selectedLocation?.place_id && needsDescriptionEnrichment(selectedLocation)) { + await enrichSelectedLocationDescription(); + } + + const prefill = buildPrefillPayload(); + if (prefill) { + dispatch('addDetails', { prefill }); + return; + } + + dispatch('manual'); + } + + async function quickAdd() { + const prefill = buildPrefillPayload(); + if (!prefill) { + addToast('warning', 'Please select a place or drop a pin first'); + return; + } + + isQuickAdding = true; + try { + const payload: Record = { + name: prefill.name, + location: prefill.location, + latitude: prefill.latitude, + longitude: prefill.longitude, + place_id: prefill.place_id, + rating: prefill.rating, + review_count: prefill.review_count, + description: prefill.description, + website: prefill.website, + phone_number: prefill.phone_number, + google_maps_url: prefill.google_maps_url, + types: prefill.types || [], + photos: prefill.photos || [], + collection_id: collectionId, + ...(selectedQuickAddCategory ? { category: selectedQuickAddCategory } : {}), + is_public: false + }; + + const res = await fetch('/api/locations/quick-add/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(errorData?.error || errorData?.detail || 'Failed to create location'); + } + + quickAddedLocation = await res.json(); + + addToast('success', 'Location created successfully'); + dispatch('quickAdded', { location: quickAddedLocation, prefill }); + } catch (error) { + addToast('error', error instanceof Error ? error.message : 'Failed to create location'); + } finally { + isQuickAdding = false; + } + } + onMount(() => { return () => { clearTimeout(searchTimeout); @@ -253,90 +531,119 @@
- -
-
-
- -
- - -
-
- -
- - {#if searchQuery && !selectedLocation} - - {/if} + {#if quickAddedLocation} +
+
+
+
+ +
+
+

Location added

+

{quickAddedLocation.name}

+
+ + +
+
+
+ {/if} - - {#if isSearching} -
- - {$t('adventures.searching')}... +
+
+
+ +
+
+
- {:else if searchResults.length > 0} -
- - -
- {#each searchResults as result} - + {/if} +
+
+ + {#if isSearching} +
+ + {$t('adventures.searching')}... +
+ {:else if searchResults.length > 0} +
+ +
+ {#each searchResults as result} + - {/each} -
+
+ + {/each}
- {/if} - - -
-
OR
+ {/if} - +
+
{$t('adventures.or') || 'OR'}
+ +
-
@@ -347,7 +654,7 @@ {#if selectedMarker} {/if}
@@ -362,99 +669,130 @@
{$t('adventures.getting_location_details')}... + >{$t('adventures.getting_location_details') || 'Getting details...'} +
{/if} -
- - - - {#if selectedMarker} - - - - {/if} - -
+ + + + {#if selectedMarker} + + + + {/if} +
- {#if selectedLocation && selectedMarker}
-
-
- -
+
+ {#if selectedLocation.photos && selectedLocation.photos.length > 0} + {selectedLocation.name} + {/if}

{$t('adventures.location_selected')}

-

{selectedLocation.name}

+

{selectedLocation.name}

+

{selectedLocation.location}

+ {#if selectedLocation.rating} +
+ + {selectedLocation.rating} + {#if selectedLocation.review_count} + ({selectedLocation.review_count} reviews) + {/if} +
+ {/if} + {#if isEnrichingDescription} +
+ + Improving description quality... +
+ {/if}

{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}

- {#if selectedLocation.category} -

- {selectedLocation.category} • {selectedLocation.type || 'location'} -

- {/if} - - - {#if locationData?.city || locationData?.region || locationData?.country} -
- {#if locationData.city} -
- 🏙️ {locationData.city.name} -
- {/if} - {#if locationData.region} -
- 🗺️ {locationData.region.name} -
- {/if} - {#if locationData.country} -
- 🌎 {locationData.country.name} -
- {/if} + {#if selectedLocation.types && selectedLocation.types.length > 0} +
+ {#each selectedLocation.types.slice(0, 5) as typeName} + {typeName} + {/each}
{/if} - - {#if locationData?.display_name} -

- {locationData.display_name} -

- {/if}
+ + {#if googleEnabled} +
+
+
+ +
+ +
+

+ Optional. If not selected, backend defaults to General. +

+
+
+
+ {/if} {/if} - -
- - + + {#if selectedLocation && selectedMarker && googleEnabled} + + + {:else} + + {/if}
diff --git a/frontend/src/lib/components/shared/LocationSearchMap.svelte b/frontend/src/lib/components/shared/LocationSearchMap.svelte index a5c8b500..9dbb6515 100644 --- a/frontend/src/lib/components/shared/LocationSearchMap.svelte +++ b/frontend/src/lib/components/shared/LocationSearchMap.svelte @@ -72,6 +72,10 @@ let initialTransportationApplied = false; let isInitializing = false; + function isFiniteCoordinatePair(lat: unknown, lng: unknown): boolean { + return Number.isFinite(Number(lat)) && Number.isFinite(Number(lng)); + } + // Track any provided codes (airport / station / etc) let startCode: string | null = null; let endCode: string | null = null; @@ -572,7 +576,25 @@ endLocationData = metaData; } else { locationData = metaData; - displayName = data.display_name; + const resolvedLocationName = (data.location_name || '').trim(); + const resolvedDisplayName = (data.display_name || '').trim(); + + if (selectedLocation) { + const isCoordinatePlaceholder = selectedLocation.name.startsWith('Location at '); + selectedLocation = { + ...selectedLocation, + name: + resolvedLocationName || + (isCoordinatePlaceholder && resolvedDisplayName + ? resolvedDisplayName + : selectedLocation.name), + location: resolvedDisplayName || selectedLocation.location + }; + emitUpdate(selectedLocation); + } + + displayName = resolvedDisplayName || resolvedLocationName || displayName; + searchQuery = selectedLocation?.name || searchQuery; } } else { if (target === 'start') { @@ -641,7 +663,11 @@ dispatch('clear'); } - $: if (!initialApplied && initialSelection) { + $: if ( + !initialApplied && + initialSelection && + isFiniteCoordinatePair(initialSelection.lat, initialSelection.lng) + ) { initialApplied = true; applyInitialSelection(initialSelection); } diff --git a/frontend/src/lib/location-save.ts b/frontend/src/lib/location-save.ts new file mode 100644 index 00000000..07a3b4f1 --- /dev/null +++ b/frontend/src/lib/location-save.ts @@ -0,0 +1,92 @@ +import { DEFAULT_CURRENCY, normalizeMoneyPayload } from '$lib/money'; +import type { Location } from '$lib/types'; + +type SaveLocationInput = { + location: Partial; + locationToEdit?: { id: string } | null; + collectionId?: string | null; + defaultCurrency?: string; +}; + +function toFixedCoordinate(value: unknown): number | null { + if (value === null || value === undefined) return null; + const parsed = typeof value === 'string' ? Number(value) : Number(value); + if (Number.isNaN(parsed)) return null; + return Number(parsed.toFixed(6)); +} + +function sanitizeLink(value: unknown): string | null { + if (!value || typeof value !== 'string' || !value.trim()) { + return null; + } + + try { + new URL(value); + return value; + } catch { + return null; + } +} + +function parseApiError(errorData: any): string { + let errorMsg = errorData?.detail || errorData?.name?.[0] || ''; + if (errorMsg) return String(errorMsg); + + const fieldErrors = Object.entries(errorData || {}) + .filter(([_, value]) => Array.isArray(value)) + .map(([key, value]) => `${key}: ${(value as string[]).join(', ')}`) + .join('; '); + + return fieldErrors || 'Failed to save location'; +} + +export async function saveLocation({ + location, + locationToEdit = null, + collectionId = null, + defaultCurrency = DEFAULT_CURRENCY +}: SaveLocationInput): Promise { + const payload: Record = { + ...location, + latitude: toFixedCoordinate(location.latitude), + longitude: toFixedCoordinate(location.longitude), + link: sanitizeLink(location.link), + description: + typeof location.description === 'string' && location.description.trim() + ? location.description + : null + }; + + if (collectionId) { + payload.collections = [collectionId]; + } + + if (location.price === null || location.price === undefined) { + payload.price = null; + payload.price_currency = null; + } else { + const normalized = normalizeMoneyPayload(payload, 'price', 'price_currency', defaultCurrency); + payload.price = normalized.price; + payload.price_currency = normalized.price_currency; + } + + const isUpdate = Boolean(locationToEdit?.id); + if (isUpdate && !collectionId) { + delete payload.collections; + } + + const res = await fetch(isUpdate ? `/api/locations/${locationToEdit?.id}` : '/api/locations', { + method: isUpdate ? 'PATCH' : 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new Error(parseApiError(errorData)); + } + + return res.json(); +} diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json index d0e9e6e1..0783c14e 100644 --- a/frontend/src/locales/ja.json +++ b/frontend/src/locales/ja.json @@ -640,8 +640,7 @@ "locations": { "location": "位置", "locations": "場所", - "my_locations": "私の場所", - "best_happened_at": "最高の出来事が起きた" + "my_locations": "私の場所" }, "lodging": { "apartment": "アパート", @@ -712,8 +711,7 @@ }, "users": "ユーザー", "navigation": "ナビゲーション", - "worldtravel": "世界旅行", - "mobile_login": "モバイルログイン" + "worldtravel": "世界旅行" }, "notes": { "content": "コンテンツ", @@ -1141,29 +1139,5 @@ "trip_context_info": "旅行コンテキスト項目は、旅行全体に適用されます。たとえば、目的地そのものである場所、一般的なメモ、旅行全体にとって重要な持ち物リストなどです。", "unscheduled_items": "予定外の項目", "unscheduled_items_desc": "これらのアイテムはこの旅行にリンクされていますが、まだ特定の日に追加されていません。" - }, - "api_keys": { - "copied": "コピーしました!", - "copy": "キーをコピーする", - "create": "キーの作成", - "create_error": "APIキーの作成に失敗しました。", - "created": "作成されました", - "description": "プログラムによるアクセスのための個人用 API キーを作成します。\nキーは作成時に 1 回だけ表示されます。", - "dismiss": "却下する", - "key_created": "API キーが正常に作成されました。", - "key_name_placeholder": "キー名 (例: ホームアシスタント)", - "key_revoked": "API キーが取り消されました。", - "last_used": "最後に使用した", - "never_used": "一度も使用されていない", - "new_key_title": "新しい API キーを保存します", - "new_key_warning": "このキーは再度表示されません。\nそれをコピーして安全な場所に保管してください。", - "no_keys": "API キーはまだありません。", - "revoke": "取り消す", - "revoke_error": "APIキーの取り消しに失敗しました。", - "title": "APIキー", - "copy_error": "キーのコピー中にエラーが発生しました。", - "usage_middle": "ヘッダーまたはとして", - "usage_prefix": "このキーを", - "delete_confirm": "このモバイル API キーを削除してもよろしいですか?" } } diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json index d6f31032..f02f5126 100644 --- a/frontend/src/locales/ko.json +++ b/frontend/src/locales/ko.json @@ -45,7 +45,7 @@ "copied_to_clipboard": "클립 보드에 복사됨!", "copy_failed": "복사 실패", "copy_link": "링크 복사", - "create_new": "새로 만들기", + "create_new": "새로 만들기...", "date": "일자", "date_constrain": "컬렉션 일자로 제한", "date_information": "일자 정보", @@ -652,8 +652,7 @@ "users": "사용자", "admin_panel": "관리자 패널", "navigation": "항해", - "worldtravel": "세계여행", - "mobile_login": "모바일 로그인" + "worldtravel": "세계여행" }, "notes": { "content": "콘텐츠", @@ -1032,8 +1031,7 @@ "locations": { "location": "위치", "locations": "위치", - "my_locations": "내 위치", - "best_happened_at": "가장 좋은 일이 일어난 시간은 다음과 같습니다." + "my_locations": "내 위치" }, "settings_download_backup": "백업을 다운로드하십시오", "invites": { @@ -1141,29 +1139,5 @@ "trip_context_info": "여행 컨텍스트 항목은 전체 여행에 적용됩니다. 예를 들어 목적지 자체인 위치, 일반 참고 사항, 전체 여행에 중요한 짐 목록 등이 있습니다.", "unscheduled_items": "예정되지 않은 품목", "unscheduled_items_desc": "이 항목은 이 여행에 연결되어 있지만 아직 특정 날짜에 추가되지 않았습니다." - }, - "api_keys": { - "copied": "복사되었습니다!", - "copy": "키 복사", - "create": "키 생성", - "create_error": "API 키를 생성하지 못했습니다.", - "created": "생성됨", - "description": "프로그래밍 방식 액세스를 위한 개인 API 키를 만듭니다. \n키는 생성 시 한 번만 표시됩니다.", - "dismiss": "해고하다", - "key_created": "API 키가 생성되었습니다.", - "key_name_placeholder": "키 이름(예: 홈어시스턴트)", - "key_revoked": "API 키가 취소되었습니다.", - "last_used": "마지막으로 사용됨", - "never_used": "한번도 사용하지 않음", - "new_key_title": "새 API 키 저장", - "new_key_warning": "이 키는 다시 표시되지 않습니다. \n복사해서 안전한 곳에 보관하세요.", - "no_keys": "아직 API 키가 없습니다.", - "revoke": "취소", - "revoke_error": "API 키를 취소하지 못했습니다.", - "title": "API 키", - "copy_error": "키를 복사하는 중에 오류가 발생했습니다.", - "usage_middle": "헤더 또는 다음과 같이", - "usage_prefix": "다음에서 이 키를 사용하세요.", - "delete_confirm": "이 모바일 API 키를 삭제하시겠습니까?" } } diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index e073642d..faabbe8d 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -492,8 +492,7 @@ "calendar": "Kalender", "admin_panel": "Admin -paneel", "navigation": "Navigatie", - "worldtravel": "Wereldreizen", - "mobile_login": "Mobiel inloggen" + "worldtravel": "Wereldreizen" }, "auth": { "confirm_password": "Bevestig wachtwoord", @@ -1032,8 +1031,7 @@ "locations": { "location": "Locatie", "locations": "Locaties", - "my_locations": "Mijn locaties", - "best_happened_at": "Het beste gebeurde om" + "my_locations": "Mijn locaties" }, "settings_download_backup": "Download back -up", "invites": { @@ -1141,29 +1139,5 @@ "trip_context_info": "Reiscontextitems zijn van toepassing op de hele reis, bijvoorbeeld locaties die de bestemming zelf vormen, algemene opmerkingen of paklijsten die belangrijk zijn voor de hele reis.", "unscheduled_items": "Niet-geplande items", "unscheduled_items_desc": "Deze items zijn gekoppeld aan deze reis, maar nog niet toegevoegd aan een specifieke dag." - }, - "api_keys": { - "copied": "Gekopieerd!", - "copy": "Kopieer sleutel", - "create": "Sleutel maken", - "create_error": "Kan API-sleutel niet maken.", - "created": "Gemaakt", - "description": "Maak persoonlijke API-sleutels voor programmatische toegang. \nSleutels worden slechts één keer weergegeven tijdens het maken.", - "dismiss": "Afwijzen", - "key_created": "API-sleutel is succesvol aangemaakt.", - "key_name_placeholder": "Sleutelnaam (bijv. Home Assistant)", - "key_revoked": "API-sleutel ingetrokken.", - "last_used": "Laatst gebruikt", - "never_used": "Nooit gebruikt", - "new_key_title": "Sla uw nieuwe API-sleutel op", - "new_key_warning": "Deze sleutel wordt niet meer getoond. \nKopieer het en bewaar het op een veilige plek.", - "no_keys": "Nog geen API-sleutels.", - "revoke": "Herroepen", - "revoke_error": "Kan de API-sleutel niet intrekken.", - "title": "API-sleutels", - "copy_error": "Fout bij kopiëren van sleutel.", - "usage_middle": "koptekst of als", - "usage_prefix": "Gebruik deze sleutel in de", - "delete_confirm": "Weet u zeker dat u deze mobiele API-sleutel wilt verwijderen?" } } diff --git a/frontend/src/locales/no.json b/frontend/src/locales/no.json index ef9d48fa..dc0ce58d 100644 --- a/frontend/src/locales/no.json +++ b/frontend/src/locales/no.json @@ -28,8 +28,7 @@ "northernLights": "Nordlys" }, "navigation": "Navigasjon", - "worldtravel": "Verdensreise", - "mobile_login": "Mobil pålogging" + "worldtravel": "Verdensreise" }, "about": { "about": "Om", @@ -1032,8 +1031,7 @@ "locations": { "location": "Sted", "locations": "Lokasjoner", - "my_locations": "Mine lokasjoner", - "best_happened_at": "Best skjedde kl" + "my_locations": "Mine lokasjoner" }, "settings_download_backup": "Last ned sikkerhetskopi", "invites": { @@ -1141,29 +1139,5 @@ "trip_context_info": "Turkontekstelementer gjelder for hele turen – for eksempel steder som er selve destinasjonen, generelle notater eller pakkelister som er viktige for hele turen.", "unscheduled_items": "Ikke-planlagte elementer", "unscheduled_items_desc": "Disse elementene er knyttet til denne turen, men har ikke blitt lagt til en bestemt dag ennå." - }, - "api_keys": { - "copied": "Kopiert!", - "copy": "Kopier nøkkel", - "create": "Opprett nøkkel", - "create_error": "Kunne ikke opprette API-nøkkel.", - "created": "Opprettet", - "description": "Lag personlige API-nøkler for programmatisk tilgang. \nNøkler vises bare én gang ved opprettelsestidspunktet.", - "dismiss": "Avskjedige", - "key_created": "API-nøkkel opprettet.", - "key_name_placeholder": "Nøkkelnavn (f.eks. Home Assistant)", - "key_revoked": "API-nøkkel er opphevet.", - "last_used": "Sist brukt", - "never_used": "Aldri brukt", - "new_key_title": "Lagre din nye API-nøkkel", - "new_key_warning": "Denne nøkkelen vises ikke igjen. \nKopier den og oppbevar den et trygt sted.", - "no_keys": "Ingen API-nøkler ennå.", - "revoke": "Oppheve", - "revoke_error": "Kunne ikke tilbakekalle API-nøkkel.", - "title": "API-nøkler", - "copy_error": "Feil ved kopiering av nøkkel.", - "usage_middle": "header eller as", - "usage_prefix": "Bruk denne tasten i", - "delete_confirm": "Er du sikker på at du vil slette denne mobile API-nøkkelen?" } } diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 7c43c74b..5bf699c2 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -28,8 +28,7 @@ "calendar": "Kalendarz", "admin_panel": "Panel administracyjny", "navigation": "Nawigacja", - "worldtravel": "Światowa podróż", - "mobile_login": "Logowanie mobilne" + "worldtravel": "Światowa podróż" }, "about": { "about": "O aplikacji", @@ -1032,8 +1031,7 @@ "locations": { "location": "Lokalizacja", "locations": "Lokalizacje", - "my_locations": "Moje lokalizacje", - "best_happened_at": "Najlepsze wydarzyło się o godz" + "my_locations": "Moje lokalizacje" }, "settings_download_backup": "Pobierz kopię zapasową", "invites": { @@ -1141,29 +1139,5 @@ "trip_context_info": "Elementy kontekstu podróży dotyczą całej podróży — na przykład lokalizacje będące samym celem podróży, uwagi ogólne lub listy rzeczy do spakowania ważne dla całej podróży.", "unscheduled_items": "Niezaplanowane pozycje", "unscheduled_items_desc": "Te elementy są powiązane z tą podróżą, ale nie zostały jeszcze dodane do konkretnego dnia." - }, - "api_keys": { - "copied": "Skopiowano!", - "copy": "Skopiuj klucz", - "create": "Utwórz klucz", - "create_error": "Nie udało się utworzyć klucza API.", - "created": "Stworzony", - "description": "Twórz osobiste klucze API w celu uzyskania dostępu programowego. \nKlucze są wyświetlane tylko raz w momencie tworzenia.", - "dismiss": "Odrzucać", - "key_created": "Klucz API został utworzony pomyślnie.", - "key_name_placeholder": "Nazwa klucza (np. Asystent domowy)", - "key_revoked": "Klucz API unieważniony.", - "last_used": "Ostatnio używany", - "never_used": "Nigdy nie używany", - "new_key_title": "Zapisz swój nowy klucz API", - "new_key_warning": "Ten klucz nie będzie już więcej wyświetlany. \nSkopiuj go i przechowuj w bezpiecznym miejscu.", - "no_keys": "Nie ma jeszcze kluczy API.", - "revoke": "Unieważnić", - "revoke_error": "Nie udało się unieważnić klucza API.", - "title": "Klucze API", - "copy_error": "Błąd podczas kopiowania klucza.", - "usage_middle": "nagłówek lub jako", - "usage_prefix": "Użyj tego klawisza w", - "delete_confirm": "Czy na pewno chcesz usunąć ten klucz mobilnego API?" } }