diff --git a/.github/workflows/adventurelog-bot.yml b/.github/workflows/adventurelog-bot.yml index d03a9c31..567720eb 100644 --- a/.github/workflows/adventurelog-bot.yml +++ b/.github/workflows/adventurelog-bot.yml @@ -61,9 +61,9 @@ jobs: await safeClosePr(); } - // Ignore specific user - if (context.actor === "seanmorley15") { - console.log("Skipping maintainer PR"); + // Ignore PRs created by the maintainer to avoid blocking their work, as well as dependabot + if (context.actor === "seanmorley15" || context.actor === "dependabot") { + console.log("Skipping maintainer or dependabot PR"); return; } 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 time 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/serializers.py b/backend/server/adventures/serializers.py index 5b0115e8..3d0ac204 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1060,6 +1060,7 @@ class CollectionItineraryDaySerializer(CustomModelSerializer): return super().update(instance, validated_data) class CollectionItineraryItemSerializer(CustomModelSerializer): + date = serializers.DateField(required=False, allow_null=True, default=None) item = serializers.SerializerMethodField() start_datetime = serializers.ReadOnlyField() end_datetime = serializers.ReadOnlyField() @@ -1069,6 +1070,33 @@ class CollectionItineraryItemSerializer(CustomModelSerializer): model = CollectionItineraryItem fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'is_global', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name'] read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name'] + + def validate(self, attrs): + data = super().validate(attrs) + + is_global = data.get('is_global') + if is_global is None and self.instance is not None: + is_global = self.instance.is_global + + if 'date' in data: + date = data.get('date') + elif self.instance is not None: + date = self.instance.date + else: + date = None + + if is_global and date is not None: + raise serializers.ValidationError({ + 'date': 'Global items must not have a date.', + 'is_global': 'Provide either a date or set is_global, not both.', + }) + + if not is_global and date is None and self.instance is None: + raise serializers.ValidationError({ + 'date': 'Dated items must include a date. To create a trip-wide item, set is_global=true.', + }) + + return data def update(self, instance, validated_data): # Security: Prevent changing collection, content_type, or object_id after creation diff --git a/backend/server/adventures/tests.py b/backend/server/adventures/tests.py index 7ce503c2..3041c9d2 100644 --- a/backend/server/adventures/tests.py +++ b/backend/server/adventures/tests.py @@ -1,3 +1,57 @@ -from django.test import TestCase +from rest_framework.test import APITestCase -# Create your tests here. +from adventures.models import Collection, CollectionItineraryItem, Location +from users.models import CustomUser + + +class ItineraryAPITestCase(APITestCase): + def setUp(self): + self.user = CustomUser.objects.create_user( + username='itinerary-user', + email='itinerary-user@example.com', + password='testpassword123', + ) + self.collection = Collection.objects.create(user=self.user, name='Test Trip') + self.location = Location.objects.create(user=self.user, name='Test Location', is_public=True) + self.client.force_authenticate(user=self.user) + + def test_create_global_itinerary_item_without_date(self): + response = self.client.post( + '/api/itineraries/', + { + 'collection': str(self.collection.id), + 'content_type': 'location', + 'object_id': str(self.location.id), + 'is_global': True, + 'order': 0, + }, + format='json', + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(CollectionItineraryItem.objects.count(), 1) + + item = CollectionItineraryItem.objects.get() + self.assertTrue(item.is_global) + self.assertIsNone(item.date) + self.assertEqual(item.collection, self.collection) + + payload = response.json() + self.assertTrue(payload['is_global']) + self.assertIsNone(payload['date']) + + def test_create_dated_itinerary_item_without_date_is_rejected(self): + response = self.client.post( + '/api/itineraries/', + { + 'collection': str(self.collection.id), + 'content_type': 'location', + 'object_id': str(self.location.id), + 'is_global': False, + 'order': 0, + }, + format='json', + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()['date'][0], 'Dated items must include a date. To create a trip-wide item, set is_global=true.') diff --git a/backend/server/adventures/views/import_export_view.py b/backend/server/adventures/views/import_export_view.py index 39dc303c..6270d018 100644 --- a/backend/server/adventures/views/import_export_view.py +++ b/backend/server/adventures/views/import_export_view.py @@ -100,6 +100,103 @@ class BackupViewSet(viewsets.ViewSet): normalized_currency = default_currency return amount, normalized_currency + + def _serialize_images(self, images_qs): + """Serialize ContentImage queryset into backup-safe dicts.""" + serialized = [] + for image in images_qs.all(): + entry = { + 'immich_id': image.immich_id, + 'is_primary': image.is_primary, + 'filename': None, + } + if image.image: + entry['filename'] = image.image.name.split('/')[-1] + serialized.append(entry) + return serialized + + def _serialize_attachments(self, attachments_qs): + """Serialize ContentAttachment queryset into backup-safe dicts.""" + serialized = [] + for attachment in attachments_qs.all(): + entry = { + 'name': attachment.name, + 'filename': None, + } + if attachment.file: + entry['filename'] = attachment.file.name.split('/')[-1] + serialized.append(entry) + return serialized + + def _add_storage_file_to_zip(self, zip_file, storage_name, arcname, files_added): + """Read a Django storage file and add it to the zip once.""" + if not storage_name or storage_name in files_added: + return + + with default_storage.open(storage_name) as storage_file: + zip_file.writestr(arcname, storage_file.read()) + files_added.add(storage_name) + + def _import_images(self, images_data, zip_file, user, content_type, object_id, summary): + created = [] + for img_data in images_data or []: + immich_id = (img_data or {}).get('immich_id') + if immich_id: + created.append( + ContentImage.objects.create( + user=user, + immich_id=immich_id, + is_primary=(img_data or {}).get('is_primary', False), + content_type=content_type, + object_id=object_id, + ) + ) + summary['images'] += 1 + continue + + filename = (img_data or {}).get('filename') + if not filename: + continue + + try: + img_content = zip_file.read(f'images/{filename}') + except KeyError: + continue + + img_file = ContentFile(img_content, name=filename) + created.append( + ContentImage.objects.create( + user=user, + image=img_file, + is_primary=(img_data or {}).get('is_primary', False), + content_type=content_type, + object_id=object_id, + ) + ) + summary['images'] += 1 + + return created + + def _import_attachments(self, attachments_data, zip_file, user, content_type, object_id, summary): + for att_data in attachments_data or []: + filename = (att_data or {}).get('filename') + if not filename: + continue + + try: + att_content = zip_file.read(f'attachments/{filename}') + except KeyError: + continue + + att_file = ContentFile(att_content, name=filename) + ContentAttachment.objects.create( + user=user, + file=att_file, + name=(att_data or {}).get('name'), + content_type=content_type, + object_id=object_id, + ) + summary['attachments'] += 1 @action(detail=False, methods=['get']) def export(self, request): @@ -148,9 +245,11 @@ class BackupViewSet(viewsets.ViewSet): # Track images so we can reference them for collection primary images image_export_map = {} + collection_id_to_export_id = {} # Export Collections for idx, collection in enumerate(user.collection_set.all()): + collection_id_to_export_id[collection.id] = idx export_data['collections'].append({ 'export_id': idx, # Add unique identifier for this export 'name': collection.name, @@ -200,7 +299,9 @@ class BackupViewSet(viewsets.ViewSet): 'end_date': visit.end_date.isoformat() if visit.end_date else None, 'timezone': visit.timezone, 'notes': visit.notes, - 'activities': [] + 'activities': [], + 'images': [], + 'attachments': [], } # Add activities for this visit @@ -239,6 +340,20 @@ class BackupViewSet(viewsets.ViewSet): visit_data['activities'].append(activity_data) location_data['visits'].append(visit_data) + + # Add visit images/attachments (generic) + visit_data['images'] = self._serialize_images(visit.images) + visit_data['attachments'] = self._serialize_attachments(visit.attachments) + + for image_index, image in enumerate(visit.images.all()): + image_export_map[image.id] = { + 'content_type': 'visit', + 'location_export_id': idx, + 'visit_export_id': visit_idx, + 'image_index': image_index, + 'immich_id': image.immich_id, + 'filename': image.image.name.split('/')[-1] if image.image else None, + } # Add trails for this location for trail in location.trails.all(): @@ -251,48 +366,28 @@ class BackupViewSet(viewsets.ViewSet): location_data['trails'].append(trail_data) # Add images + location_data['images'] = self._serialize_images(location.images) for image_index, image in enumerate(location.images.all()): - image_data = { - 'immich_id': image.immich_id, - 'is_primary': image.is_primary, - 'filename': None, - } - if image.image: - image_data['filename'] = image.image.name.split('/')[-1] - location_data['images'].append(image_data) - image_export_map[image.id] = { + 'content_type': 'location', 'location_export_id': idx, 'image_index': image_index, 'immich_id': image.immich_id, - 'filename': image_data['filename'], + 'filename': image.image.name.split('/')[-1] if image.image else None, } # Add attachments - for attachment in location.attachments.all(): - attachment_data = { - 'name': attachment.name, - 'filename': None - } - if attachment.file: - attachment_data['filename'] = attachment.file.name.split('/')[-1] - location_data['attachments'].append(attachment_data) + location_data['attachments'] = self._serialize_attachments(location.attachments) export_data['locations'].append(location_data) - # Attach collection primary image references (if any) - for idx, collection in enumerate(user.collection_set.all()): - primary = collection.primary_image - if primary and primary.id in image_export_map: - export_data['collections'][idx]['primary_image'] = image_export_map[primary.id] - # Export Transportation for idx, transport in enumerate(user.transportation_set.all()): collection_export_id = None if transport.collection: collection_export_id = collection_name_to_id.get(transport.collection.name) - export_data['transportation'].append({ + transport_data = { 'export_id': idx, 'type': transport.type, 'name': transport.name, @@ -313,8 +408,20 @@ class BackupViewSet(viewsets.ViewSet): 'destination_longitude': str(transport.destination_longitude) if transport.destination_longitude else None, 'to_location': transport.to_location, 'is_public': transport.is_public, - 'collection_export_id': collection_export_id - }) + 'collection_export_id': collection_export_id, + 'images': self._serialize_images(transport.images), + 'attachments': self._serialize_attachments(transport.attachments), + } + export_data['transportation'].append(transport_data) + + for image_index, image in enumerate(transport.images.all()): + image_export_map[image.id] = { + 'content_type': 'transportation', + 'object_export_id': idx, + 'image_index': image_index, + 'immich_id': image.immich_id, + 'filename': image.image.name.split('/')[-1] if image.image else None, + } # Export Notes for idx, note in enumerate(user.note_set.all()): @@ -322,15 +429,27 @@ class BackupViewSet(viewsets.ViewSet): if note.collection: collection_export_id = collection_name_to_id.get(note.collection.name) - export_data['notes'].append({ + note_data = { 'export_id': idx, 'name': note.name, 'content': note.content, 'links': note.links, 'date': note.date.isoformat() if note.date else None, 'is_public': note.is_public, - 'collection_export_id': collection_export_id - }) + 'collection_export_id': collection_export_id, + 'images': self._serialize_images(note.images), + 'attachments': self._serialize_attachments(note.attachments), + } + export_data['notes'].append(note_data) + + for image_index, image in enumerate(note.images.all()): + image_export_map[image.id] = { + 'content_type': 'note', + 'object_export_id': idx, + 'image_index': image_index, + 'immich_id': image.immich_id, + 'filename': image.image.name.split('/')[-1] if image.image else None, + } # Export Checklists for idx, checklist in enumerate(user.checklist_set.all()): @@ -362,7 +481,7 @@ class BackupViewSet(viewsets.ViewSet): if lodging.collection: collection_export_id = collection_name_to_id.get(lodging.collection.name) - export_data['lodging'].append({ + lodging_data = { 'export_id': idx, 'name': lodging.name, 'type': lodging.type, @@ -379,8 +498,30 @@ class BackupViewSet(viewsets.ViewSet): 'longitude': str(lodging.longitude) if lodging.longitude else None, 'location': lodging.location, 'is_public': lodging.is_public, - 'collection_export_id': collection_export_id - }) + 'collection_export_id': collection_export_id, + 'images': self._serialize_images(lodging.images), + 'attachments': self._serialize_attachments(lodging.attachments), + } + export_data['lodging'].append(lodging_data) + + for image_index, image in enumerate(lodging.images.all()): + image_export_map[image.id] = { + 'content_type': 'lodging', + 'object_export_id': idx, + 'image_index': image_index, + 'immich_id': image.immich_id, + 'filename': image.image.name.split('/')[-1] if image.image else None, + } + + # Attach collection primary image references (if any) + for collection in user.collection_set.all(): + export_id = collection_id_to_export_id.get(collection.id) + if export_id is None: + continue + + primary = collection.primary_image + if primary and primary.id in image_export_map: + export_data['collections'][export_id]['primary_image'] = image_export_map[primary.id] # Export Itinerary Items # Create export_id mappings for all content types @@ -431,35 +572,153 @@ class BackupViewSet(viewsets.ViewSet): for image in location.images.all(): if image.image and image.image.name not in files_added: try: - image_content = default_storage.open(image.image.name).read() filename = image.image.name.split('/')[-1] - zip_file.writestr(f'images/{filename}', image_content) - files_added.add(image.image.name) + self._add_storage_file_to_zip( + zip_file, + image.image.name, + f'images/{filename}', + files_added, + ) except Exception as e: print(f"Error adding image {image.image.name}: {e}") + + # Add visit images + for visit in location.visits.all(): + for image in visit.images.all(): + if image.image and image.image.name not in files_added: + try: + filename = image.image.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + image.image.name, + f'images/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding visit image {image.image.name}: {e}") # Add attachments for attachment in location.attachments.all(): if attachment.file and attachment.file.name not in files_added: try: - file_content = default_storage.open(attachment.file.name).read() filename = attachment.file.name.split('/')[-1] - zip_file.writestr(f'attachments/{filename}', file_content) - files_added.add(attachment.file.name) + self._add_storage_file_to_zip( + zip_file, + attachment.file.name, + f'attachments/{filename}', + files_added, + ) except Exception as e: print(f"Error adding attachment {attachment.file.name}: {e}") + + # Add visit attachments + for visit in location.visits.all(): + for attachment in visit.attachments.all(): + if attachment.file and attachment.file.name not in files_added: + try: + filename = attachment.file.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + attachment.file.name, + f'attachments/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding visit attachment {attachment.file.name}: {e}") # Add GPX files from activities for visit in location.visits.all(): for activity in visit.activities.all(): if activity.gpx_file and activity.gpx_file.name not in files_added: try: - gpx_content = default_storage.open(activity.gpx_file.name).read() filename = activity.gpx_file.name.split('/')[-1] - zip_file.writestr(f'gpx/{filename}', gpx_content) - files_added.add(activity.gpx_file.name) + self._add_storage_file_to_zip( + zip_file, + activity.gpx_file.name, + f'gpx/{filename}', + files_added, + ) except Exception as e: print(f"Error adding GPX file {activity.gpx_file.name}: {e}") + + # Add non-location content images/attachments + for transport in user.transportation_set.all(): + for image in transport.images.all(): + if image.image and image.image.name not in files_added: + try: + filename = image.image.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + image.image.name, + f'images/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding transportation image {image.image.name}: {e}") + for attachment in transport.attachments.all(): + if attachment.file and attachment.file.name not in files_added: + try: + filename = attachment.file.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + attachment.file.name, + f'attachments/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding transportation attachment {attachment.file.name}: {e}") + + for note in user.note_set.all(): + for image in note.images.all(): + if image.image and image.image.name not in files_added: + try: + filename = image.image.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + image.image.name, + f'images/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding note image {image.image.name}: {e}") + for attachment in note.attachments.all(): + if attachment.file and attachment.file.name not in files_added: + try: + filename = attachment.file.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + attachment.file.name, + f'attachments/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding note attachment {attachment.file.name}: {e}") + + for lodging in user.lodging_set.all(): + for image in lodging.images.all(): + if image.image and image.image.name not in files_added: + try: + filename = image.image.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + image.image.name, + f'images/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding lodging image {image.image.name}: {e}") + for attachment in lodging.attachments.all(): + if attachment.file and attachment.file.name not in files_added: + try: + filename = attachment.file.name.split('/')[-1] + self._add_storage_file_to_zip( + zip_file, + attachment.file.name, + f'attachments/{filename}', + files_added, + ) + except Exception as e: + print(f"Error adding lodging attachment {attachment.file.name}: {e}") # Return ZIP file as response with open(tmp_file.name, 'rb') as zip_file: @@ -611,6 +870,16 @@ class BackupViewSet(viewsets.ViewSet): pending_primary_images = [] location_images_map = {} + visit_images_map = {} + transportation_images_map = {} + note_images_map = {} + lodging_images_map = {} + + content_type_location = ContentType.objects.get(model='location') + content_type_visit = ContentType.objects.get(model='visit') + content_type_transportation = ContentType.objects.get(model='transportation') + content_type_note = ContentType.objects.get(model='note') + content_type_lodging = ContentType.objects.get(model='lodging') # Import Collections for col_data in backup_data.get('collections', []): @@ -721,6 +990,10 @@ class BackupViewSet(viewsets.ViewSet): timezone=visit_data.get('timezone'), notes=visit_data.get('notes') ) + + visit_export_id = visit_data.get('export_id') + if visit_export_id is not None: + visit_images_map.setdefault((adv_data['export_id'], visit_export_id), []) # Import activities for this visit for activity_data in visit_data.get('activities', []): @@ -783,77 +1056,50 @@ class BackupViewSet(viewsets.ViewSet): activity.save() summary['activities'] += 1 + + # Import visit images/attachments (if present) + created_visit_images = self._import_images( + visit_data.get('images', []), + zip_file, + user, + content_type_visit, + visit.id, + summary, + ) + if visit_export_id is not None: + visit_images_map[(adv_data['export_id'], visit_export_id)].extend(created_visit_images) + + self._import_attachments( + visit_data.get('attachments', []), + zip_file, + user, + content_type_visit, + visit.id, + summary, + ) # Import images - content_type = ContentType.objects.get(model='location') + created_location_images = self._import_images( + adv_data.get('images', []), + zip_file, + user, + content_type_location, + location.id, + summary, + ) + location_images_map[adv_data['export_id']].extend(created_location_images) - for img_data in adv_data.get('images', []): - immich_id = img_data.get('immich_id') - if immich_id: - new_img = ContentImage.objects.create( - user=user, - immich_id=immich_id, - is_primary=img_data.get('is_primary', False), - content_type=content_type, - object_id=location.id - ) - location_images_map[adv_data['export_id']].append(new_img) - summary['images'] += 1 - else: - filename = img_data.get('filename') - if filename: - try: - img_content = zip_file.read(f'images/{filename}') - img_file = ContentFile(img_content, name=filename) - new_img = ContentImage.objects.create( - user=user, - image=img_file, - is_primary=img_data.get('is_primary', False), - content_type=content_type, - object_id=location.id - ) - location_images_map[adv_data['export_id']].append(new_img) - summary['images'] += 1 - except KeyError: - pass - - # Import attachments - for att_data in adv_data.get('attachments', []): - filename = att_data.get('filename') - if filename: - try: - att_content = zip_file.read(f'attachments/{filename}') - att_file = ContentFile(att_content, name=filename) - ContentAttachment.objects.create( - user=user, - file=att_file, - name=att_data.get('name'), - content_type=content_type, - object_id=location.id - ) - summary['attachments'] += 1 - except KeyError: - pass + self._import_attachments( + adv_data.get('attachments', []), + zip_file, + user, + content_type_location, + location.id, + summary, + ) summary['locations'] += 1 - # Apply primary image selections now that images exist - for entry in pending_primary_images: - collection = collection_map.get(entry['collection_export_id']) - data = entry.get('data', {}) or {} - if not collection: - continue - - loc_export_id = data.get('location_export_id') - img_index = data.get('image_index') - if loc_export_id is None or img_index is None: - continue - - images_for_location = location_images_map.get(loc_export_id, []) - if 0 <= img_index < len(images_for_location): - collection.primary_image = images_for_location[img_index] - collection.save(update_fields=['primary_image']) - # Import Transportation transportation_map = {} # Map export_id to actual transportation object for trans_data in backup_data.get('transportation', []): @@ -889,6 +1135,28 @@ class BackupViewSet(viewsets.ViewSet): is_public=trans_data.get('is_public', False), collection=collection ) + + export_id = trans_data.get('export_id') + if export_id is not None: + transportation_images_map.setdefault(export_id, []) + transportation_images_map[export_id].extend( + self._import_images( + trans_data.get('images', []), + zip_file, + user, + content_type_transportation, + transportation.id, + summary, + ) + ) + self._import_attachments( + trans_data.get('attachments', []), + zip_file, + user, + content_type_transportation, + transportation.id, + summary, + ) # Only add to map if export_id exists (for backward compatibility with old backups) if 'export_id' in trans_data: transportation_map[trans_data['export_id']] = transportation @@ -910,6 +1178,28 @@ class BackupViewSet(viewsets.ViewSet): is_public=note_data.get('is_public', False), collection=collection ) + + export_id = note_data.get('export_id') + if export_id is not None: + note_images_map.setdefault(export_id, []) + note_images_map[export_id].extend( + self._import_images( + note_data.get('images', []), + zip_file, + user, + content_type_note, + note.id, + summary, + ) + ) + self._import_attachments( + note_data.get('attachments', []), + zip_file, + user, + content_type_note, + note.id, + summary, + ) # Only add to map if export_id exists (for backward compatibility with old backups) if 'export_id' in note_data: note_map[note_data['export_id']] = note @@ -976,10 +1266,77 @@ class BackupViewSet(viewsets.ViewSet): is_public=lodg_data.get('is_public', False), collection=collection ) + + export_id = lodg_data.get('export_id') + if export_id is not None: + lodging_images_map.setdefault(export_id, []) + lodging_images_map[export_id].extend( + self._import_images( + lodg_data.get('images', []), + zip_file, + user, + content_type_lodging, + lodging.id, + summary, + ) + ) + self._import_attachments( + lodg_data.get('attachments', []), + zip_file, + user, + content_type_lodging, + lodging.id, + summary, + ) # Only add to map if export_id exists (for backward compatibility with old backups) if 'export_id' in lodg_data: lodging_map[lodg_data['export_id']] = lodging summary['lodging'] += 1 + + # Apply primary image selections now that images exist + for entry in pending_primary_images: + collection = collection_map.get(entry['collection_export_id']) + data = entry.get('data', {}) or {} + if not collection: + continue + + content_type_str = data.get('content_type') or 'location' + img_index = data.get('image_index') + if img_index is None: + continue + + if content_type_str == 'location': + loc_export_id = data.get('location_export_id') + if loc_export_id is None: + continue + images_for_object = location_images_map.get(loc_export_id, []) + elif content_type_str == 'visit': + loc_export_id = data.get('location_export_id') + visit_export_id = data.get('visit_export_id') + if loc_export_id is None or visit_export_id is None: + continue + images_for_object = visit_images_map.get((loc_export_id, visit_export_id), []) + elif content_type_str == 'transportation': + obj_export_id = data.get('object_export_id') + if obj_export_id is None: + continue + images_for_object = transportation_images_map.get(obj_export_id, []) + elif content_type_str == 'note': + obj_export_id = data.get('object_export_id') + if obj_export_id is None: + continue + images_for_object = note_images_map.get(obj_export_id, []) + elif content_type_str == 'lodging': + obj_export_id = data.get('object_export_id') + if obj_export_id is None: + continue + images_for_object = lodging_images_map.get(obj_export_id, []) + else: + continue + + if 0 <= img_index < len(images_for_object): + collection.primary_image = images_for_object[img_index] + collection.save(update_fields=['primary_image']) # Import Itinerary Items # Maps already created during import of each content type diff --git a/backend/server/adventures/views/itinerary_view.py b/backend/server/adventures/views/itinerary_view.py index f6ddae34..318d88b3 100644 --- a/backend/server/adventures/views/itinerary_view.py +++ b/backend/server/adventures/views/itinerary_view.py @@ -54,6 +54,8 @@ class ItineraryViewSet(viewsets.ModelViewSet): if isinstance(is_global, str): is_global = is_global.lower() in ['1', 'true', 'yes'] data['is_global'] = is_global + if is_global and not target_date: + data['date'] = None # Support legacy field 'location' -> treat as content_type='location' if not content_type_val and data.get('location'): diff --git a/backend/server/adventures/views/location_image_view.py b/backend/server/adventures/views/location_image_view.py index d1a9c4b0..27a5d177 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.response import Response 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 @@ from adventures.permissions import IsOwnerOrSharedWithFullAccess # Your existin import requests from adventures.permissions import ContentImagePermission import logging +import uuid logger = logging.getLogger(__name__) @@ -25,6 +29,17 @@ class ImageProxyThrottle(UserRateThrottle): scope = 'image_proxy' +def _public_import_error_message(exc): + """Return a safe, user-facing import error without exposing internal details.""" + if isinstance(exc, ValueError): + return "Invalid image URL" + if isinstance(exc, requests.exceptions.Timeout): + return "Download timeout" + if isinstance(exc, requests.exceptions.RequestException): + return "Failed to fetch image from the remote server" + return "Image import failed" + + def _is_safe_url(image_url): """ Validate a URL for safe proxy use. @@ -67,6 +82,149 @@ 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: + logger.warning( + "Image import failed for URL %s", + image_url, + exc_info=True, + ) + downloaded_results.append((index, image_url, None, _public_import_error_message(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 +350,12 @@ class ContentImageViewSet(viewsets.ModelViewSet): 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 + image_data = download_remote_image(str(image_url).strip()) + return HttpResponse(image_data['content'], content_type=image_data['content_type'], status=200) - 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) + except ValueError: + return Response({"error": "Invalid image URL"}, status=status.HTTP_400_BAD_REQUEST) except requests.exceptions.Timeout: logger.error("Timeout fetching image from URL %s", image_url) @@ -269,6 +370,64 @@ class ContentImageViewSet(viewsets.ModelViewSet): 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..2f9ae8a6 100644 --- a/backend/server/adventures/views/location_view.py +++ b/backend/server/adventures/views/location_view.py @@ -12,8 +12,31 @@ import requests from adventures.models import Location, Category, Collection, CollectionItineraryItem, ContentImage, Visit from django.contrib.contenttypes.models import ContentType from adventures.permissions import IsOwnerOrSharedWithFullAccess -from adventures.serializers import LocationSerializer, MapPinSerializer, CalendarLocationSerializer +from adventures.serializers import ( + CalendarLocationSerializer, + CollectionItineraryItemSerializer, + LocationSerializer, + MapPinSerializer, +) from adventures.utils import pagination +from adventures.geocoding import reverse_geocode +from worldtravel.models import City, Country, Region +from .location_image_view import import_remote_images_for_object +from .quick_add_utils import ( + build_quick_add_description, + clean_url, + coerce_bool, + coerce_coordinate, + coerce_float, + coerce_int, + create_quick_add_itinerary_item, + extract_google_place_details, + parse_itinerary_date, + preferred_link, + resolve_quick_add_collection, + sanitize_photo_urls, + sanitize_tags, +) logger = logging.getLogger(__name__) @@ -158,6 +181,122 @@ class LocationViewSet(viewsets.ModelViewSet): # ==================== 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 = coerce_coordinate(payload.get('latitude'), -90, 90) + longitude = 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 + + reverse_data = {} + _, details = extract_google_place_details(payload, fallback_query=name) + + 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 = {} + + rating = coerce_float(payload.get('rating')) + if rating is None: + rating = coerce_float(details.get('rating')) + + review_count = coerce_int(payload.get('review_count')) + if review_count is None: + review_count = coerce_int(details.get('review_count')) + + link = preferred_link(payload, details) + + 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 = 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': sanitize_tags(payload.get('types') or payload.get('tags')), + 'is_public': 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) + + itinerary_date = parse_itinerary_date(payload.get('itinerary_date')) + itinerary_item = None + if collection and itinerary_date: + itinerary_item = create_quick_add_itinerary_item(collection, location, itinerary_date) + if isinstance(itinerary_item, Response): + return itinerary_item + + photo_urls = 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 itinerary_item: + response_data['quick_add_itinerary_item'] = CollectionItineraryItemSerializer( + itinerary_item + ).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 +599,122 @@ class LocationViewSet(viewsets.ModelViewSet): f"You don't have permission to add location to collection '{collection.name}'" ) + def _resolve_quick_add_collection(self, collection_id): + return resolve_quick_add_collection( + collection_id, + validate_permissions=self._validate_collection_permissions, + permission_error_message=( + "You do not have permission to add this location to the selected collection." + ), + ) + + def _coerce_coordinate(self, value, min_value, max_value): + return coerce_coordinate(value, min_value, max_value) + + def _coerce_float(self, value): + return coerce_float(value) + + def _coerce_int(self, value): + return coerce_int(value) + + def _coerce_bool(self, value, default=False): + return coerce_bool(value, default=default) + + def _clean_url(self, value): + return clean_url(value) + + def _sanitize_tags(self, raw_tags): + return sanitize_tags(raw_tags) + + def _sanitize_photo_urls(self, raw_urls): + return sanitize_photo_urls(raw_urls) + + 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, + ): + return build_quick_add_description(base_description, detailed_description) + + 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/lodging_view.py b/backend/server/adventures/views/lodging_view.py index 159c127a..d530fa1b 100644 --- a/backend/server/adventures/views/lodging_view.py +++ b/backend/server/adventures/views/lodging_view.py @@ -1,12 +1,27 @@ from rest_framework import viewsets, status from rest_framework.decorators import action from rest_framework.response import Response +from django.db import transaction from django.db.models import Q from adventures.models import Lodging -from adventures.serializers import LodgingSerializer +from adventures.serializers import CollectionItineraryItemSerializer, LodgingSerializer from rest_framework.exceptions import PermissionDenied from adventures.permissions import IsOwnerOrSharedWithFullAccess -from rest_framework.permissions import IsAuthenticated +from adventures.geocoding import reverse_geocode +from .location_image_view import import_remote_images_for_object +from .quick_add_utils import ( + build_quick_add_description, + coerce_bool, + coerce_coordinate, + coerce_float, + create_quick_add_itinerary_item, + extract_google_place_details, + infer_lodging_type, + parse_itinerary_date, + preferred_link, + resolve_quick_add_collection, + sanitize_photo_urls, +) class LodgingViewSet(viewsets.ModelViewSet): queryset = Lodging.objects.all() @@ -63,6 +78,114 @@ class LodgingViewSet(viewsets.ModelViewSet): def perform_update(self, serializer): serializer.save() + + @action(detail=False, methods=['post'], url_path='quick-add') + @transaction.atomic + def quick_add(self, request): + """Create a lodging 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 = coerce_coordinate(payload.get('latitude'), -90, 90) + longitude = 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 = resolve_quick_add_collection( + payload.get('collection_id'), + validate_permissions=self._validate_collection_permissions, + permission_error_message=( + "You do not have permission to add this lodging to the selected collection." + ), + ) + if isinstance(collection, Response): + return collection + + reverse_data = {} + 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 = {} + + _, details = extract_google_place_details(payload, fallback_query=name) + + rating = coerce_float(payload.get('rating')) + if rating is None: + rating = coerce_float(details.get('rating')) + + 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 + ) + + place_types = payload.get('types') + if not isinstance(place_types, list) or not place_types: + place_types = details.get('types') if isinstance(details.get('types'), list) else [] + + serializer_payload = { + 'name': name, + 'type': infer_lodging_type(payload.get('type'), place_types), + 'location': location_label, + 'latitude': latitude, + 'longitude': longitude, + 'rating': rating, + 'description': build_quick_add_description( + base_description=payload.get('description'), + detailed_description=details.get('description'), + ), + 'link': preferred_link(payload, details), + 'is_public': coerce_bool(payload.get('is_public'), default=False), + } + + if collection: + serializer_payload['collection'] = str(collection.id) + + serializer = self.get_serializer(data=serializer_payload) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + + lodging = serializer.instance + + itinerary_date = parse_itinerary_date(payload.get('itinerary_date')) + itinerary_item = None + if collection and itinerary_date: + itinerary_item = create_quick_add_itinerary_item(collection, lodging, itinerary_date) + if isinstance(itinerary_item, Response): + return itinerary_item + + photo_urls = sanitize_photo_urls(payload.get('photos')) + image_import_summary = None + if photo_urls: + image_import_summary = import_remote_images_for_object( + lodging, + photo_urls, + owner=lodging.user, + max_workers=min(5, len(photo_urls)), + ) + + response_data = self.get_serializer(lodging).data + if itinerary_item: + response_data['quick_add_itinerary_item'] = CollectionItineraryItemSerializer( + itinerary_item + ).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) # when creating an adventure, make sure the user is the owner of the collection or shared with the collection def perform_create(self, serializer): @@ -81,4 +204,13 @@ class LodgingViewSet(viewsets.ModelViewSet): return # Save the adventure with the current user as the owner - serializer.save(user=self.request.user) \ No newline at end of file + serializer.save(user=self.request.user) + + def _validate_collection_permissions(self, collections): + """Validate permissions for all collections (used by quick add).""" + for collection in collections: + if collection.user != self.request.user: + if not collection.shared_with.filter(id=self.request.user.id).exists(): + raise PermissionDenied( + f"You don't have permission to add lodging to collection '{collection.name}'" + ) \ No newline at end of file diff --git a/backend/server/adventures/views/quick_add_utils.py b/backend/server/adventures/views/quick_add_utils.py new file mode 100644 index 00000000..37478a75 --- /dev/null +++ b/backend/server/adventures/views/quick_add_utils.py @@ -0,0 +1,325 @@ +import datetime +from urllib.parse import urlparse + +from django.core.exceptions import PermissionDenied as DjangoPermissionDenied +from django.db import models +from django.utils.dateparse import parse_date, parse_datetime +from rest_framework import status +from rest_framework.exceptions import PermissionDenied as DRFPermissionDenied +from rest_framework.response import Response + +from django.contrib.contenttypes.models import ContentType + +from adventures.geocoding import get_place_details +from adventures.models import Collection, CollectionItineraryItem, Visit + + +def coerce_coordinate(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(value): + try: + return float(value) + except (TypeError, ValueError): + return None + + +def coerce_int(value): + try: + return int(value) + except (TypeError, ValueError): + return None + + +def coerce_bool(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(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(raw_tags, max_tags=8): + 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) >= max_tags: + break + + return tags + + +def sanitize_photo_urls(raw_urls, max_urls=5): + if not isinstance(raw_urls, list): + return [] + + cleaned = [] + for value in raw_urls: + url = clean_url(value) + if not url or url in cleaned: + continue + + cleaned.append(url) + if len(cleaned) >= max_urls: + break + + return cleaned + + +def build_quick_add_description(base_description, detailed_description): + description = str(detailed_description or "").strip() or str(base_description or "").strip() + return description or None + + +def resolve_quick_add_collection(collection_id, validate_permissions, permission_error_message): + 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: + validate_permissions([collection]) + except (DjangoPermissionDenied, DRFPermissionDenied): + return Response( + {"error": permission_error_message}, + status=status.HTTP_403_FORBIDDEN, + ) + + return collection + + +def extract_google_place_details(payload, fallback_query=""): + place_id = str(payload.get("place_id") or "").strip() or None + details = {} + + if not place_id: + return place_id, details + + details_result = get_place_details(place_id, fallback_query=fallback_query) + if isinstance(details_result, dict): + if "error" not in details_result or details_result.get("description"): + details = details_result + + return place_id, details + + +def preferred_link(payload, details): + website = clean_url(details.get("website")) or clean_url(payload.get("website")) + maps_url = clean_url(details.get("google_maps_url")) or clean_url(payload.get("google_maps_url")) + return clean_url(payload.get("link")) or website or maps_url + + +def infer_lodging_type(primary_type, place_types): + valid_types = { + "hotel", + "hostel", + "resort", + "bnb", + "campground", + "cabin", + "apartment", + "house", + "villa", + "motel", + "other", + } + + if isinstance(primary_type, str): + normalized = primary_type.strip().lower() + if normalized in valid_types: + return normalized + + normalized_types = [ + str(type_name).strip().lower() + for type_name in (place_types or []) + if str(type_name).strip() + ] + + mapping = { + "hotel": "hotel", + "resort_hotel": "resort", + "motel": "motel", + "hostel": "hostel", + "bed_and_breakfast": "bnb", + "guest_house": "bnb", + "campground": "campground", + "rv_park": "campground", + "camping_cabin": "cabin", + "apartment_building": "apartment", + "lodging": "hotel", + "villa": "villa", + } + + for type_name in normalized_types: + if type_name in mapping: + return mapping[type_name] + + for type_name in normalized_types: + if type_name in valid_types: + return type_name + + return "other" + + +def parse_itinerary_date(value): + if not value: + return None + + raw_value = str(value).strip() + if not raw_value: + return None + + parsed_date = parse_date(raw_value) + if parsed_date: + return parsed_date + + parsed_datetime = parse_datetime(raw_value) + if parsed_datetime: + return parsed_datetime.date() + + return None + + +def validate_itinerary_date(collection, date_value): + if not collection or not date_value: + return None + + if collection.start_date and date_value < collection.start_date: + return Response( + {"error": "Itinerary item date is before the collection start_date"}, + status=status.HTTP_400_BAD_REQUEST, + ) + if collection.end_date and date_value > collection.end_date: + return Response( + {"error": "Itinerary item date is after the collection end_date"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return None + + +def apply_quick_add_itinerary_date(content_object, date_value): + if not content_object or not date_value: + return + + model_name = content_object._meta.model_name + + if model_name == "location": + start_dt = datetime.datetime.combine(date_value, datetime.time.min) + end_dt = datetime.datetime.combine(date_value, datetime.time.max) + + exact_match = Visit.objects.filter( + location=content_object, start_date=start_dt, end_date=end_dt + ).first() + if exact_match: + return + + overlap_q = models.Q(start_date__lte=end_dt) & models.Q(end_date__gte=start_dt) + existing = Visit.objects.filter(location=content_object).filter(overlap_q).first() + if existing: + existing.start_date = start_dt + existing.end_date = end_dt + existing.save(update_fields=["start_date", "end_date"]) + return + + Visit.objects.create( + location=content_object, + start_date=start_dt, + end_date=end_dt, + notes="Created from quick add", + ) + return + + if model_name == "lodging": + if content_object.check_in and content_object.check_out: + return + + check_in = datetime.datetime.combine(date_value, datetime.time.min) + check_out = check_in + datetime.timedelta(days=1) + content_object.check_in = check_in + content_object.check_out = check_out + content_object.save(update_fields=["check_in", "check_out"]) + + +def create_quick_add_itinerary_item(collection, content_object, date_value): + if not collection or not content_object or not date_value: + return None + + existing_error = validate_itinerary_date(collection, date_value) + if isinstance(existing_error, Response): + return existing_error + + content_type = ContentType.objects.get_for_model(content_object.__class__) + existing_item = CollectionItineraryItem.objects.filter( + collection=collection, + content_type=content_type, + object_id=content_object.id, + date=date_value, + is_global=False, + ).first() + if existing_item: + return existing_item + + max_order = ( + CollectionItineraryItem.objects.filter( + collection=collection, date=date_value, is_global=False + ).aggregate(max_order=models.Max("order"))["max_order"] + or -1 + ) + + apply_quick_add_itinerary_date(content_object, date_value) + + return CollectionItineraryItem.objects.create( + collection=collection, + content_type=content_type, + object_id=content_object.id, + date=date_value, + is_global=False, + order=max_order + 1, + ) 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.models import Location 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 @@ class ReverseGeocodeViewSet(viewsets.ViewSet): "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/backend/server/requirements.txt b/backend/server/requirements.txt index 69668bd1..ef6659a4 100644 --- a/backend/server/requirements.txt +++ b/backend/server/requirements.txt @@ -1,4 +1,4 @@ -Django==5.2.12 +Django==5.2.13 djangorestframework>=3.15.2,<3.16 django-allauth==0.63.3 django-money==3.5.4 @@ -8,7 +8,7 @@ django-cors-headers==4.4.0 coreapi==2.3.3 python-dotenv==1.1.0 psycopg2-binary==2.9.10 -pillow==12.1.1 +pillow==12.2.0 whitenoise==6.9.0 django-resized==1.0.3 django-geojson==4.2.0 diff --git a/backend/server/templates/base.html b/backend/server/templates/base.html index 205445ee..24234e17 100644 --- a/backend/server/templates/base.html +++ b/backend/server/templates/base.html @@ -175,7 +175,7 @@ `API Response: ${data.status} ${data.statusText}
Content: ${data.responseText}` ); }; - const susccess_response = (data) => { + const success_response = (data) => { $(".api-response").html( `API Response: OK
Content: ${JSON.stringify( data, @@ -190,7 +190,7 @@ const form = $("form.ajax-post"); $.post(form.attr("action"), form.serialize()) .fail(error_response) - .done(susccess_response); + .done(success_response); return false; }); }); diff --git a/backend/server/users/models.py b/backend/server/users/models.py index 27fd8a1e..a224c335 100644 --- a/backend/server/users/models.py +++ b/backend/server/users/models.py @@ -1,6 +1,7 @@ import hashlib import secrets import uuid +from django.conf import settings from django.contrib.auth.models import AbstractUser from django.db import models from django_resized import ResizedImageField @@ -49,12 +50,15 @@ class APIKey(models.Model): Security design: - A 32-byte cryptographically random token is generated with the prefix ``al_``. - - Only a SHA-256 hash of the full token is persisted; the plaintext is returned - exactly once at creation time and never stored. + - Only a PBKDF2-HMAC-SHA256 derived hash of the full token is persisted; + the plaintext is returned exactly once at creation time and never stored. - The first 12 characters of the token are kept as ``key_prefix`` so users can - identify their keys without revealing the secret. + identify their keys without revealing the secret. """ + _KEY_HASH_ITERATIONS = 600000 + _KEY_HASH_SALT_NAMESPACE = "users.APIKey" + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) user = models.ForeignKey( CustomUser, on_delete=models.CASCADE, related_name='api_keys' @@ -71,6 +75,17 @@ class APIKey(models.Model): def __str__(self): return f"{self.user.username} – {self.name} ({self.key_prefix}…)" + @classmethod + def _hash_raw_key(cls, raw_key: str) -> str: + """Derive a computationally expensive hash for API key persistence.""" + salt = f"{cls._KEY_HASH_SALT_NAMESPACE}:{settings.SECRET_KEY}".encode("utf-8") + return hashlib.pbkdf2_hmac( + "sha256", + raw_key.encode("utf-8"), + salt, + cls._KEY_HASH_ITERATIONS, + ).hex() + @classmethod def generate(cls, user, name: str) -> tuple['APIKey', str]: """ @@ -80,7 +95,7 @@ class APIKey(models.Model): user once and must never be stored anywhere after that. """ raw_key = f"al_{secrets.token_urlsafe(32)}" - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + key_hash = cls._hash_raw_key(raw_key) key_prefix = raw_key[:12] instance = cls.objects.create( user=user, @@ -98,7 +113,7 @@ class APIKey(models.Model): Returns the matching ``APIKey`` instance (updating ``last_used_at``) or ``None`` if not found. """ - key_hash = hashlib.sha256(raw_key.encode()).hexdigest() + key_hash = cls._hash_raw_key(raw_key) try: api_key = cls.objects.select_related('user').get(key_hash=key_hash) except cls.DoesNotExist: diff --git a/documentation/docs/install/docker.md b/documentation/docs/install/docker.md index d574287c..3e5d4208 100644 --- a/documentation/docs/install/docker.md +++ b/documentation/docs/install/docker.md @@ -62,7 +62,7 @@ The `.env` file contains all the configuration settings for your AdventureLog in | `FRONTEND_URL` | Yes | URL to the **frontend**, used for email generation. | `http://localhost:8015` | | `BACKEND_PORT` | Yes | Port that the backend will run on inside Docker. | `8016` | | `DEBUG` | No | Should be `False` in production. | `False` | -| `ENABLE_RATE_LIMITS` | No | Enable rate limits on the backend. Should be `True` in production. | `True` | +| `ENABLE_RATE_LIMITS` | No | Enable rate limits on the backend. Should be `True` in production. | `False` | ## Optional Configuration diff --git a/frontend/package.json b/frontend/package.json index a54c7b95..537caf02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,7 @@ "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-vercel": "^5.7.0", "@sveltejs/kit": "^2.49.5", - "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@sveltejs/vite-plugin-svelte": "3.1.2", "@tailwindcss/typography": "^0.5.19", "@types/node": "^22.15.2", "@types/qrcode": "^1.5.5", @@ -30,7 +30,7 @@ "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-svelte": "^3.3.3", - "svelte": "^4.2.19", + "svelte": "4.2.19", "svelte-check": "^3.8.6", "tailwindcss": "^3.4.17", "tslib": "^2.8.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 2ba80d43..624a073d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -11,8 +11,6 @@ overrides: brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5' picomatch@<2.3.2: '>=2.3.2' picomatch@>=4.0.0 <4.0.4: '>=4.0.4' - svelte@<=5.51.4: '>=5.51.5' - svelte@<=5.53.4: '>=5.53.5' importers: @@ -20,7 +18,7 @@ importers: dependencies: '@lukulent/svelte-umami': specifier: ^0.0.3 - version: 0.0.3(svelte@5.55.1) + version: 0.0.3(svelte@4.2.19) dompurify: specifier: ^3.2.5 version: 3.3.3 @@ -44,13 +42,13 @@ importers: version: 1.5.4 svelte-dnd-action: specifier: ^0.9.68 - version: 0.9.69(svelte@5.55.1) + version: 0.9.69(svelte@4.2.19) svelte-i18n: specifier: ^4.0.1 - version: 4.0.1(svelte@5.55.1) + version: 4.0.1(svelte@4.2.19) svelte-maplibre: specifier: ^0.9.14 - version: 0.9.14(svelte@5.55.1) + version: 0.9.14(svelte@4.2.19) devDependencies: '@event-calendar/core': specifier: ^3.12.0 @@ -69,16 +67,16 @@ importers: version: 1.2.3 '@sveltejs/adapter-node': specifier: ^5.2.12 - version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))) + version: 5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))) '@sveltejs/adapter-vercel': specifier: '>=6.3.2' - version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0) + version: 6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0) '@sveltejs/kit': specifier: ^2.49.5 - version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) + version: 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) '@sveltejs/vite-plugin-svelte': - specifier: ^3.1.2 - version: 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)) + specifier: 3.1.2 + version: 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)) '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19) @@ -105,13 +103,13 @@ importers: version: 3.8.1 prettier-plugin-svelte: specifier: ^3.3.3 - version: 3.5.1(prettier@3.8.1)(svelte@5.55.1) + version: 3.5.1(prettier@3.8.1)(svelte@4.2.19) svelte: - specifier: '>=5.53.5' - version: 5.55.1 + specifier: 4.2.19 + version: 4.2.19 svelte-check: specifier: ^3.8.6 - version: 3.8.6(postcss@8.5.8)(svelte@5.55.1) + version: 3.8.6(postcss@8.5.8)(svelte@4.2.19) tailwindcss: specifier: ^3.4.17 version: 3.4.19 @@ -134,6 +132,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@antfu/install-pkg@0.4.1': resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==} @@ -345,9 +347,6 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -361,7 +360,7 @@ packages: '@lukulent/svelte-umami@0.0.3': resolution: {integrity: sha512-4pL0sJapfy14yDj6CyZgewbRDadRoBJtk/dLqCJh7/tQuX7HO4hviBzhrVa4Osxaq2kcGEKdpkhAKAoaNdlNSA==} peerDependencies: - svelte: '>=5.53.5' + svelte: ^4.0.0 '@mapbox/geojson-rewind@0.5.2': resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} @@ -611,7 +610,7 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 - svelte: '>=5.53.5' + svelte: ^4.0.0 || ^5.0.0-next.0 typescript: ^5.3.3 vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 peerDependenciesMeta: @@ -625,14 +624,14 @@ packages: engines: {node: ^18.0.0 || >=20} peerDependencies: '@sveltejs/vite-plugin-svelte': ^3.0.0 - svelte: '>=5.53.5' + svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.0 '@sveltejs/vite-plugin-svelte@3.1.2': resolution: {integrity: sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==} engines: {node: ^18.0.0 || >=20} peerDependencies: - svelte: '>=5.53.5' + svelte: ^4.0.0 || ^5.0.0-next.0 vite: ^5.0.0 '@tailwindcss/typography@0.5.19': @@ -682,10 +681,6 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} - '@typescript-eslint/types@8.58.0': - resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vercel/nft@1.5.0': resolution: {integrity: sha512-IWTDeIoWhQ7ZtRO/JRKH+jhmeQvZYhtGPmzw/QGDY+wDCQqfm25P9yIdoAFagu4fWsK4IwZXDFIjrmp5rRm/sA==} engines: {node: '>=20'} @@ -810,9 +805,8 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} + code-red@1.0.4: + resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -848,6 +842,10 @@ packages: css-selector-tokenizer@0.8.0: resolution: {integrity: sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==} + css-tree@2.3.1: + resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -965,12 +963,12 @@ packages: resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==} engines: {node: '>=0.10'} - esrap@2.2.4: - resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + event-emitter@0.3.5: resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==} @@ -1218,6 +1216,9 @@ packages: engines: {node: '>= 18'} hasBin: true + mdn-data@2.0.30: + resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==} + memoizee@0.4.17: resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==} engines: {node: '>=0.12'} @@ -1360,6 +1361,9 @@ packages: resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} hasBin: true + periscopic@3.1.0: + resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1450,7 +1454,7 @@ packages: resolution: {integrity: sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==} peerDependencies: prettier: ^3.0.0 - svelte: '>=5.53.5' + svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} @@ -1588,25 +1592,25 @@ packages: resolution: {integrity: sha512-ij0u4Lw/sOTREP13BdWZjiXD/BlHE6/e2e34XzmVmsp5IN4kVa3PWP65NM32JAgwjZlwBg/+JtiNV1MM8khu0Q==} hasBin: true peerDependencies: - svelte: '>=5.53.5' + svelte: ^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 svelte-dnd-action@0.9.69: resolution: {integrity: sha512-NAmSOH7htJoYraTQvr+q5whlIuVoq88vEuHr4NcFgscDRUxfWPPxgie2OoxepBCQCikrXZV4pqV86aun60wVyw==} peerDependencies: - svelte: '>=5.53.5' + svelte: '>=3.23.0 || ^5.0.0-next.0' svelte-hmr@0.16.0: resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==} engines: {node: ^12.20 || ^14.13.1 || >= 16} peerDependencies: - svelte: '>=5.53.5' + svelte: ^3.19.0 || ^4.0.0 svelte-i18n@4.0.1: resolution: {integrity: sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==} engines: {node: '>= 16'} hasBin: true peerDependencies: - svelte: '>=5.53.5' + svelte: ^3 || ^4 || ^5 svelte-maplibre@0.9.14: resolution: {integrity: sha512-5HBvibzU/Uf3g8eEz4Hty5XAwoBhW9Tp7NQEvb80U/glR/M1IHyzUKss6XMq8Zbci2wtsASeoPc6dA5R4+0e0w==} @@ -1614,7 +1618,7 @@ packages: '@deck.gl/core': ^8.8.0 '@deck.gl/layers': ^8.8.0 '@deck.gl/mapbox': ^8.8.0 - svelte: '>=5.53.5' + svelte: ^3.54.0 || ^4.0.0 || ^5.0.0 peerDependenciesMeta: '@deck.gl/core': optional: true @@ -1636,7 +1640,7 @@ packages: sass: ^1.26.8 stylus: ^0.55.0 sugarss: ^2.0.0 || ^3.0.0 || ^4.0.0 - svelte: '>=5.53.5' + svelte: ^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0 typescript: '>=3.9.5 || ^4.0.0 || ^5.0.0' peerDependenciesMeta: '@babel/core': @@ -1660,9 +1664,9 @@ packages: typescript: optional: true - svelte@5.55.1: - resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==} - engines: {node: '>=18'} + svelte@4.2.19: + resolution: {integrity: sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==} + engines: {node: '>=16'} tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} @@ -1846,13 +1850,15 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@antfu/install-pkg@0.4.1': dependencies: package-manager-detector: 0.2.11 @@ -1947,22 +1953,22 @@ snapshots: '@event-calendar/core@3.12.0': dependencies: - svelte: 5.55.1 + svelte: 4.2.19 '@event-calendar/day-grid@3.12.0': dependencies: '@event-calendar/core': 3.12.0 - svelte: 5.55.1 + svelte: 4.2.19 '@event-calendar/interaction@3.12.0': dependencies: '@event-calendar/core': 3.12.0 - svelte: 5.55.1 + svelte: 4.2.19 '@event-calendar/time-grid@3.12.0': dependencies: '@event-calendar/core': 3.12.0 - svelte: 5.55.1 + svelte: 4.2.19 '@formatjs/ecma402-abstract@2.3.6': dependencies: @@ -2018,11 +2024,6 @@ snapshots: '@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2032,9 +2033,9 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lukulent/svelte-umami@0.0.3(svelte@5.55.1)': + '@lukulent/svelte-umami@0.0.3(svelte@4.2.19)': dependencies: - svelte: 5.55.1 + svelte: 4.2.19 '@mapbox/geojson-rewind@0.5.2': dependencies: @@ -2209,17 +2210,17 @@ snapshots: dependencies: acorn: 8.16.0 - '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))': + '@sveltejs/adapter-node@5.5.4(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))': dependencies: '@rollup/plugin-commonjs': 29.0.2(rollup@4.59.0) '@rollup/plugin-json': 6.1.0(rollup@4.59.0) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.59.0) - '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) rollup: 4.59.0 - '@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)': + '@sveltejs/adapter-vercel@6.3.3(@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)))(rollup@4.59.0)': dependencies: - '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) + '@sveltejs/kit': 2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15)) '@vercel/nft': 1.5.0(rollup@4.59.0) esbuild: 0.25.12 transitivePeerDependencies: @@ -2227,11 +2228,11 @@ snapshots: - rollup - supports-color - '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))': + '@sveltejs/kit@2.55.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(typescript@5.9.3)(vite@5.4.21(@types/node@22.19.15))': dependencies: '@standard-schema/spec': 1.1.0 '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)) + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)) '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 @@ -2242,29 +2243,29 @@ snapshots: mrmime: 2.0.1 set-cookie-parser: 3.0.1 sirv: 3.0.2 - svelte: 5.55.1 + svelte: 4.2.19 vite: 5.4.21(@types/node@22.19.15) optionalDependencies: typescript: 5.9.3 - '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))': + '@sveltejs/vite-plugin-svelte-inspector@2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))': dependencies: - '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)) + '@sveltejs/vite-plugin-svelte': 3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)) debug: 4.4.3 - svelte: 5.55.1 + svelte: 4.2.19 vite: 5.4.21(@types/node@22.19.15) transitivePeerDependencies: - supports-color - '@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15))': + '@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15))': dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)))(svelte@5.55.1)(vite@5.4.21(@types/node@22.19.15)) + '@sveltejs/vite-plugin-svelte-inspector': 2.1.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)))(svelte@4.2.19)(vite@5.4.21(@types/node@22.19.15)) debug: 4.4.3 deepmerge: 4.3.1 kleur: 4.1.5 magic-string: 0.30.21 - svelte: 5.55.1 - svelte-hmr: 0.16.0(svelte@5.55.1) + svelte: 4.2.19 + svelte-hmr: 0.16.0(svelte@4.2.19) vite: 5.4.21(@types/node@22.19.15) vitefu: 0.2.5(vite@5.4.21(@types/node@22.19.15)) transitivePeerDependencies: @@ -2315,9 +2316,8 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 - '@types/trusted-types@2.0.7': {} - - '@typescript-eslint/types@8.58.0': {} + '@types/trusted-types@2.0.7': + optional: true '@vercel/nft@1.5.0(rollup@4.59.0)': dependencies: @@ -2447,7 +2447,13 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 - clsx@2.1.1: {} + code-red@1.0.4: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@types/estree': 1.0.8 + acorn: 8.16.0 + estree-walker: 3.0.3 + periscopic: 3.1.0 color-convert@2.0.1: dependencies: @@ -2474,6 +2480,11 @@ snapshots: cssesc: 3.0.0 fastparse: 1.1.2 + css-tree@2.3.1: + dependencies: + mdn-data: 2.0.30 + source-map-js: 1.2.1 + cssesc@3.0.0: {} culori@3.3.0: {} @@ -2603,13 +2614,12 @@ snapshots: event-emitter: 0.3.5 type: 2.7.3 - esrap@2.2.4: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@typescript-eslint/types': 8.58.0 - estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + event-emitter@0.3.5: dependencies: d: 1.0.2 @@ -2851,6 +2861,8 @@ snapshots: marked@15.0.12: {} + mdn-data@2.0.30: {} + memoizee@0.4.17: dependencies: d: 1.0.2 @@ -2972,6 +2984,12 @@ snapshots: ieee754: 1.2.1 resolve-protobuf-schema: 2.1.0 + periscopic@3.1.0: + dependencies: + '@types/estree': 1.0.8 + estree-walker: 3.0.3 + is-reference: 3.0.3 + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3045,10 +3063,10 @@ snapshots: potpack@2.1.0: {} - prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@5.55.1): + prettier-plugin-svelte@3.5.1(prettier@3.8.1)(svelte@4.2.19): dependencies: prettier: 3.8.1 - svelte: 5.55.1 + svelte: 4.2.19 prettier@3.8.1: {} @@ -3203,14 +3221,14 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - svelte-check@3.8.6(postcss@8.5.8)(svelte@5.55.1): + svelte-check@3.8.6(postcss@8.5.8)(svelte@4.2.19): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 3.6.0 picocolors: 1.1.1 sade: 1.8.1 - svelte: 5.55.1 - svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3) + svelte: 4.2.19 + svelte-preprocess: 5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -3223,15 +3241,15 @@ snapshots: - stylus - sugarss - svelte-dnd-action@0.9.69(svelte@5.55.1): + svelte-dnd-action@0.9.69(svelte@4.2.19): dependencies: - svelte: 5.55.1 + svelte: 4.2.19 - svelte-hmr@0.16.0(svelte@5.55.1): + svelte-hmr@0.16.0(svelte@4.2.19): dependencies: - svelte: 5.55.1 + svelte: 4.2.19 - svelte-i18n@4.0.1(svelte@5.55.1): + svelte-i18n@4.0.1(svelte@4.2.19): dependencies: cli-color: 2.0.4 deepmerge: 4.3.1 @@ -3239,10 +3257,10 @@ snapshots: estree-walker: 2.0.2 intl-messageformat: 10.7.18 sade: 1.8.1 - svelte: 5.55.1 + svelte: 4.2.19 tiny-glob: 0.2.9 - svelte-maplibre@0.9.14(svelte@5.55.1): + svelte-maplibre@0.9.14(svelte@4.2.19): dependencies: d3-geo: 3.1.1 dequal: 2.0.3 @@ -3250,38 +3268,36 @@ snapshots: just-flush: 2.3.0 maplibre-gl: 4.7.1 pmtiles: 3.2.1 - svelte: 5.55.1 + svelte: 4.2.19 - svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@5.55.1)(typescript@5.9.3): + svelte-preprocess@5.1.4(postcss@8.5.8)(svelte@4.2.19)(typescript@5.9.3): dependencies: '@types/pug': 2.0.10 detect-indent: 6.1.0 magic-string: 0.30.21 sorcery: 0.11.1 strip-indent: 3.0.0 - svelte: 5.55.1 + svelte: 4.2.19 optionalDependencies: postcss: 8.5.8 typescript: 5.9.3 - svelte@5.55.1: + svelte@4.2.19: dependencies: - '@jridgewell/remapping': 2.3.5 + '@ampproject/remapping': 2.3.0 '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@jridgewell/trace-mapping': 0.3.31 '@types/estree': 1.0.8 - '@types/trusted-types': 2.0.7 acorn: 8.16.0 aria-query: 5.3.1 axobject-query: 4.1.0 - clsx: 2.1.1 - devalue: 5.6.4 - esm-env: 1.2.2 - esrap: 2.2.4 + code-red: 1.0.4 + css-tree: 2.3.1 + estree-walker: 3.0.3 is-reference: 3.0.3 locate-character: 3.0.0 magic-string: 0.30.21 - zimmerframe: 1.1.4 + periscopic: 3.1.0 tailwindcss@3.4.19: dependencies: @@ -3457,5 +3473,3 @@ snapshots: which-module: 2.0.1 y18n: 4.0.3 yargs-parser: 18.1.3 - - zimmerframe@1.1.4: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index d66e1150..c3322a78 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -5,5 +5,3 @@ overrides: brace-expansion@>=4.0.0 <5.0.5: '>=5.0.5' picomatch@<2.3.2: '>=2.3.2' picomatch@>=4.0.0 <4.0.4: '>=4.0.4' - svelte@<=5.51.4: '>=5.51.5' - svelte@<=5.53.4: '>=5.53.5' diff --git a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte index 04dfcd00..d96ea437 100644 --- a/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte +++ b/frontend/src/lib/components/collections/CollectionItineraryPlanner.svelte @@ -396,6 +396,79 @@ return value.includes('T') ? value.split('T')[0] : value; } + function upsertItineraryItem(newItem: CollectionItineraryItem) { + if (!newItem) return; + const itinerary = collection.itinerary ? [...collection.itinerary] : []; + const idMatchIndex = itinerary.findIndex((it) => String(it.id) === String(newItem.id)); + if (idMatchIndex >= 0) { + itinerary[idMatchIndex] = newItem; + collection = { ...collection, itinerary }; + return; + } + const duplicate = itinerary.some( + (it) => + String(it.object_id) === String(newItem.object_id) && + String(it.date || '') === String(newItem.date || '') && + Boolean(it.is_global) === Boolean(newItem.is_global) + ); + if (!duplicate) { + collection = { ...collection, itinerary: [...itinerary, newItem] }; + } + } + + function handleQuickAddCreated( + objectType: 'location' | 'lodging', + event: CustomEvent<{ + location: any; + itineraryItem?: CollectionItineraryItem | null; + itineraryDate?: string | null; + }> + ) { + const createdItem = event.detail?.location; + if (!createdItem) return; + + if (objectType === 'location') { + const locs = collection.locations ? [...collection.locations] : []; + const idx = locs.findIndex((loc) => String(loc.id) === String(createdItem.id)); + if (idx >= 0) { + locs[idx] = { + ...locs[idx], + ...createdItem, + visits: createdItem.visits || locs[idx].visits || [] + }; + } else { + locs.unshift({ ...createdItem }); + } + collection = { ...collection, locations: locs }; + } else { + const lodgings = collection.lodging ? [...collection.lodging] : []; + const idx = lodgings.findIndex((l) => String(l.id) === String(createdItem.id)); + if (idx >= 0) { + lodgings[idx] = { ...lodgings[idx], ...createdItem }; + } else { + lodgings.unshift({ ...createdItem }); + } + collection = { ...collection, lodging: lodgings }; + } + + const itineraryItem = event.detail?.itineraryItem || null; + if (itineraryItem) { + upsertItineraryItem(itineraryItem); + addedToItinerary.add(String(createdItem.id)); + addedToItinerary = addedToItinerary; + } else if (event.detail?.itineraryDate) { + void addItineraryItemForObject( + objectType, + String(createdItem.id), + String(event.detail.itineraryDate) + ); + addedToItinerary.add(String(createdItem.id)); + addedToItinerary = addedToItinerary; + } + + pendingAddDate = null; + } + function upsertNote(note: Note) { const notes = collection.notes ? [...collection.notes] : []; const idx = notes.findIndex((n) => n.id === note.id); @@ -543,11 +616,11 @@ $: if ( locationBeingUpdated?.id && pendingAddDate && - !addedToItinerary.has(locationBeingUpdated.id) + !addedToItinerary.has(String(locationBeingUpdated.id)) ) { addItineraryItemForObject('location', locationBeingUpdated.id, pendingAddDate); // Mark this location as added to prevent duplicates - addedToItinerary.add(locationBeingUpdated.id); + addedToItinerary.add(String(locationBeingUpdated.id)); addedToItinerary = addedToItinerary; // trigger reactivity } @@ -578,7 +651,7 @@ $: if ( lodgingBeingUpdated?.id && pendingAddDate && - !addedToItinerary.has(lodgingBeingUpdated.id) + !addedToItinerary.has(String(lodgingBeingUpdated.id)) ) { // Normalize check_in to date-only (YYYY-MM-DD) if present const lodgingCheckInDate = lodgingBeingUpdated.check_in @@ -588,7 +661,7 @@ addItineraryItemForObject('lodging', lodgingBeingUpdated.id, targetDate); // Mark this lodging as added to prevent duplicates - addedToItinerary.add(lodgingBeingUpdated.id); + addedToItinerary.add(String(lodgingBeingUpdated.id)); addedToItinerary = addedToItinerary; // trigger reactivity } @@ -619,11 +692,11 @@ $: if ( transportationBeingUpdated?.id && pendingAddDate && - !addedToItinerary.has(transportationBeingUpdated.id) + !addedToItinerary.has(String(transportationBeingUpdated.id)) ) { addItineraryItemForObject('transportation', transportationBeingUpdated.id, pendingAddDate); // Mark this transportation as added to prevent duplicates - addedToItinerary.add(transportationBeingUpdated.id); + addedToItinerary.add(String(transportationBeingUpdated.id)); addedToItinerary = addedToItinerary; // trigger reactivity } @@ -1301,6 +1374,15 @@ dateISO: string, updateItemDate: boolean = false ) { + const alreadyScheduled = (collection.itinerary || []).some( + (it) => + String(it.object_id) === String(objectId) && + String(it.date || '') === String(dateISO) && + !it.is_global + ); + if (alreadyScheduled) { + return; + } const tempId = `temp-${Date.now()}`; const day = days.find((d) => d.date === dateISO); const order = day ? day.items.length : 0; @@ -1520,6 +1602,7 @@ addedToItinerary.clear(); addedToItinerary = addedToItinerary; }} + on:quickAddCreated={(e) => handleQuickAddCreated('location', e)} {user} {locationToEdit} bind:location={locationBeingUpdated} @@ -1538,6 +1621,7 @@ addedToItinerary.clear(); addedToItinerary = addedToItinerary; }} + on:quickAddCreated={(e) => handleQuickAddCreated('lodging', e)} {user} {lodgingToEdit} bind:lodging={lodgingBeingUpdated} 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) + try { + const savedLocation = await saveLocation({ + location, + locationToEdit, + collectionId: collection?.id || null, + defaultCurrency }); - } else { - res = await fetch(`/api/locations`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(payload) - }); - } - - 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..3ef81775 100644 --- a/frontend/src/lib/components/locations/LocationModal.svelte +++ b/frontend/src/lib/components/locations/LocationModal.svelte @@ -12,6 +12,7 @@ export let collection: Collection | null = null; export let initialLatLng: { lat: number; lng: number } | null = null; // Used to pass the location from the map selection to the modal export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner + export let itineraryDayLabel: string | null = null; const dispatch = createEventDispatcher(); @@ -19,6 +20,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 +51,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 +185,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 +208,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 +312,7 @@ > @@ -216,14 +322,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} + itineraryDate={storedInitialVisitDate} + itineraryLabel={itineraryDayLabel} + on:addDetails={(e) => { + applyQuickStartPrefill(e.detail.prefill); + setStep(1); + }} + on:manual={() => { + setStep(1); + }} + on:quickAdded={(e) => { + location = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + dispatch('quickAddCreated', { + location: e.detail.location, + itineraryItem: e.detail.itineraryItem || null, + itineraryDate: e.detail.itineraryDate || null + }); + 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()} - on:next={() => { - steps[0].selected = false; - steps[1].selected = true; - }} /> {/if} {#if steps[1].selected} @@ -300,55 +425,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 +476,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..8e8d000a 100644 --- a/frontend/src/lib/components/locations/LocationQuickStart.svelte +++ b/frontend/src/lib/components/locations/LocationQuickStart.svelte @@ -1,460 +1,22 @@ -
- -
-
-
- -
- - -
-
- -
- - {#if searchQuery && !selectedLocation} - - {/if} -
-
- - - {#if isSearching} -
- - {$t('adventures.searching')}... -
- {:else if searchResults.length > 0} -
- - -
- {#each searchResults as result} - - {/each} -
-
- {/if} - - -
-
OR
-
- - -
-
-
- - -
-
-
-

- - {$t('adventures.select_on_map') || 'Select on Map'} -

- {#if selectedMarker} - - {/if} -
- - {#if !selectedMarker} -

- {$t('adventures.click_map') || 'Click on the map to select a location'} -

- {/if} - - {#if isReverseGeocoding} -
- - {$t('adventures.getting_location_details')}... -
- {/if} - -
- - - - {#if selectedMarker} - - - - {/if} - -
-
-
- - - {#if selectedLocation && selectedMarker} -
-
-
-
- -
-
-

{$t('adventures.location_selected')}

-

{selectedLocation.name}

-

- {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} - - {#if locationData?.display_name} -

- {locationData.display_name} -

- {/if} -
-
-
-
- {/if} - - -
- - -
-
+ diff --git a/frontend/src/lib/components/lodging/LodgingModal.svelte b/frontend/src/lib/components/lodging/LodgingModal.svelte index d4e9d8df..876cd9ff 100644 --- a/frontend/src/lib/components/lodging/LodgingModal.svelte +++ b/frontend/src/lib/components/lodging/LodgingModal.svelte @@ -4,12 +4,15 @@ import { addToast } from '$lib/toasts'; import { t } from 'svelte-i18n'; import Bed from '~icons/mdi/bed'; + import LodgingQuickStart from './LodgingQuickStart.svelte'; import LodgingDetails from './LodgingDetails.svelte'; import MediaStep from '../shared/MediaStep.svelte'; + import { inferLodgingTypeFromPlace } from '$lib/utils/lodgingType'; export let user: User | null = null; export let collection: Collection | null = null; export let initialVisitDate: string | null = null; // Used to pre-fill visit date when adding from itinerary planner + export let itineraryDayLabel: string | null = null; const dispatch = createEventDispatcher(); @@ -17,16 +20,25 @@ 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; let steps = [ { - name: $t('adventures.details'), + name: $t('adventures.quick_start'), selected: true, requires_id: false }, + { + name: $t('adventures.details'), + selected: false, + requires_id: false + }, { name: $t('settings.media'), selected: false, @@ -34,6 +46,104 @@ } ]; + 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 && !lodging.id) { + return; + } + setStep(stepIndex); + } + + function handleDetailsBack() { + if (isEditMode) { + close(); + return; + } + setStep(0); + } + + function applyQuickStartPrefill(prefill: any) { + if (!prefill) return; + + if (prefill.name) lodging.name = prefill.name; + if (prefill.location) lodging.location = prefill.location; + if (typeof prefill.latitude === 'number') lodging.latitude = prefill.latitude; + if (typeof prefill.longitude === 'number') lodging.longitude = prefill.longitude; + if (typeof prefill.rating === 'number') lodging.rating = prefill.rating; + if (!lodging.link && (prefill.website || prefill.google_maps_url)) { + lodging.link = prefill.website || prefill.google_maps_url; + } + if (!lodging.description && prefill.description) { + lodging.description = prefill.description; + } + + if (!lodging.type) { + lodging.type = inferLodgingTypeFromPlace(prefill.type, prefill.types); + } + + pendingGooglePhotoUrls = Array.isArray(prefill.photos) + ? prefill.photos.filter((url: unknown) => typeof url === 'string' && url.trim()).slice(0, 5) + : []; + } + + async function importPendingGoogleImages(lodgingId: string) { + if (!lodgingId || 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: 'lodging', + object_id: lodgingId, + urls: pendingGooglePhotoUrls + }) + }); + + if (!res.ok) { + addToast('warning', 'Lodging 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(lodging.images) ? lodging.images : []; + const existingIds = new Set(existingImages.map((img: any) => img.id)); + const imported = data.created.filter((img: any) => !existingIds.has(img.id)); + lodging.images = [...existingImages, ...imported]; + } + + pendingGooglePhotoUrls = []; + } catch { + addToast('warning', 'Lodging 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; + } + } + function createEmptyLodging(): Lodging { return { id: '', @@ -106,9 +216,10 @@ // Only reset to empty if we don't already have a saved lodging with an ID lodging = createEmptyLodging(); storedInitialVisitDate = initialVisitDate; - // Reset steps to details when creating a new lodging + // Reset steps for a fresh lodging creation session steps = [ - { name: $t('adventures.details'), selected: true, requires_id: false }, + { name: $t('adventures.quick_start'), selected: true, requires_id: false }, + { name: $t('adventures.details'), selected: false, requires_id: false }, { name: $t('settings.media'), selected: false, requires_id: true } ]; } @@ -118,6 +229,15 @@ onMount(async () => { modal = document.getElementById('my_modal_1') as HTMLDialogElement; modal.showModal(); + isEditMode = Boolean(lodgingToEdit?.id); + + if (isEditMode) { + setStep(1); + } else { + setStep(0); + } + + void loadIntegrations(); }); function close() { @@ -200,14 +320,11 @@ ? 'bg-primary text-primary-content' : 'bg-base-200'} {step.requires_id && !lodging?.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 && !lodging?.id} + on:click={() => handleStepSelect(index)} + disabled={(step.requires_id && !lodging?.id) || (index === 0 && isEditMode)} > - {#if steps[0].selected} + {#if steps[0].selected && !isEditMode} + { + applyQuickStartPrefill(e.detail.prefill); + setStep(1); + }} + on:manual={() => { + setStep(1); + }} + on:quickAdded={(e) => { + lodging = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + dispatch('quickAddCreated', { + location: e.detail.location, + itineraryItem: e.detail.itineraryItem || null, + itineraryDate: e.detail.itineraryDate || null + }); + close(); + }} + on:quickAddedEdit={(e) => { + lodging = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + setStep(1); + }} + on:quickAddedDone={(e) => { + lodging = e.detail.location; + pendingGooglePhotoUrls = []; + didSave = true; + close(); + }} + on:cancel={() => close()} + /> + {/if} + {#if steps[1].selected} { - steps[1].selected = false; - steps[0].selected = true; - }} - on:save={(e) => { + on:back={handleDetailsBack} + on:save={async (e) => { // Update the entire lodging object with all saved data const detail = e.detail || {}; const previousImages = lodging.images || []; @@ -285,25 +438,31 @@ // Only allow moving to Media once we have a persisted id. if (!lodging?.id) { addToast('error', $t('adventures.lodging_save_error')); - steps[1].selected = false; - steps[0].selected = true; + setStep(1); return; } - steps[0].selected = false; - steps[1].selected = true; + setStep(2); + if (pendingGooglePhotoUrls.length > 0) { + await importPendingGoogleImages(lodging.id); + } }} initialVisitDate={storedInitialVisitDate} /> {/if} - {#if steps[1].selected} + {#if steps[2].selected} + {#if importingGooglePhotos} +
+ + Importing Google photos in the background. They will appear here shortly. +
+ {/if} { - steps[1].selected = false; - steps[0].selected = true; + setStep(1); }} on:close={() => close()} itemId={lodging.id} diff --git a/frontend/src/lib/components/lodging/LodgingQuickStart.svelte b/frontend/src/lib/components/lodging/LodgingQuickStart.svelte new file mode 100644 index 00000000..a4396a14 --- /dev/null +++ b/frontend/src/lib/components/lodging/LodgingQuickStart.svelte @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/lib/components/shared/ExternalMapLinks.svelte b/frontend/src/lib/components/shared/ExternalMapLinks.svelte new file mode 100644 index 00000000..532ec980 --- /dev/null +++ b/frontend/src/lib/components/shared/ExternalMapLinks.svelte @@ -0,0 +1,73 @@ + + +{#if displayName || hasCoords} +
+
+
+ Open in maps + {#if displayName} + {displayName} + {/if} +
+ {#if coordsLabel} + {coordsLabel} + {/if} +
+ +
+{/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/components/shared/PlaceQuickStart.svelte b/frontend/src/lib/components/shared/PlaceQuickStart.svelte new file mode 100644 index 00000000..8cb803ac --- /dev/null +++ b/frontend/src/lib/components/shared/PlaceQuickStart.svelte @@ -0,0 +1,863 @@ + + +
+ {#if quickAddedLocation} +
+
+
+
+ +
+
+

+ {mode === 'lodging' ? 'Lodging added' : 'Location added'} +

+

{quickAddedLocation.name}

+
+
+
+ + +
+
+
+ {/if} + +
+
+
+ +
+
+ +
+ + {#if searchQuery && !selectedLocation} + + {/if} +
+
+ + {#if isSearching} +
+ + {$t('adventures.searching')}... +
+ {:else if searchResults.length > 0} +
+ +
+ {#each searchResults as result} + + {/each} +
+
+ {/if} + +
+
{$t('adventures.or') || 'OR'}
+
+ + +
+
+ +
+
+
+

+ + {$t('adventures.select_on_map') || 'Select on Map'} +

+ {#if selectedMarker} + + {/if} +
+ + {#if !selectedMarker} +

+ {#if mode === 'lodging'} + Click on the map to select a lodging + {:else} + {$t('adventures.click_map') || 'Click on the map to select a location'} + {/if} +

+ {/if} + + {#if isReverseGeocoding} +
+ + {$t('adventures.getting_location_details') || 'Getting details...'} + +
+ {/if} + + + + + {#if selectedMarker} + + + + {/if} + +
+
+ + {#if selectedLocation && selectedMarker} +
+
+
+ {#if selectedLocation.photos && selectedLocation.photos.length > 0} + {selectedLocation.name} + {/if} +
+

+ {mode === 'lodging' + ? $t('lodging.new_lodging') || 'Lodging selected' + : $t('adventures.location_selected')} +

+

{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.types && selectedLocation.types.length > 0} +
+ {#each selectedLocation.types.slice(0, 5) as typeName} + {typeName} + {/each} +
+ {/if} +
+
+
+
+ + {#if googleEnabled && supportsCategory} +
+
+
+ +
+ +
+

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

+
+
+
+ {/if} + {/if} + + {#if itineraryDate} +
+ + Will be added to {formattedItineraryLabel || itineraryDate} + +
+ {/if} + +
+ + + {#if selectedLocation && selectedMarker && googleEnabled} + + + {:else} + + {/if} +
+
diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 1ecd0898..b984912e 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.12.0-main-040426'; +export let appVersion = 'v0.12.0-main-051526'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.0'; export let appTitle = 'AdventureLog'; export let copyrightYear = '2023-2026'; diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index 23aacad6..93b60105 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -391,7 +391,7 @@ export let LODGING_TYPES_ICONS = { apartment: '🏢', house: '🏠', villa: '🏡', - motel: '🚗🏨', + motel: '🏨', other: '❓' }; @@ -506,7 +506,7 @@ export function osmTagToEmoji(tag: string) { case 'hotel': return '🏨'; case 'motel': - return '🏩'; + return '🏨'; case 'pub': return '🍺'; case 'restaurant': 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/lib/utils/lodgingType.ts b/frontend/src/lib/utils/lodgingType.ts new file mode 100644 index 00000000..6da6c500 --- /dev/null +++ b/frontend/src/lib/utils/lodgingType.ts @@ -0,0 +1,57 @@ +const LODGING_TYPE_MAP: Record = { + hotel: 'hotel', + resort_hotel: 'resort', + motel: 'motel', + hostel: 'hostel', + bed_and_breakfast: 'bnb', + guest_house: 'bnb', + campground: 'campground', + rv_park: 'campground', + camping_cabin: 'cabin', + apartment_building: 'apartment', + lodging: 'hotel', + villa: 'villa' +}; + +const VALID_TYPES = new Set([ + 'hotel', + 'hostel', + 'resort', + 'bnb', + 'campground', + 'cabin', + 'apartment', + 'house', + 'villa', + 'motel', + 'other' +]); + +export function inferLodgingTypeFromPlace(primaryType: unknown, placeTypes: unknown): string { + if (typeof primaryType === 'string') { + const normalized = primaryType.trim().toLowerCase(); + if (VALID_TYPES.has(normalized)) { + return normalized; + } + } + + const normalizedTypes = Array.isArray(placeTypes) + ? placeTypes + .map((typeName) => (typeof typeName === 'string' ? typeName.trim().toLowerCase() : '')) + .filter(Boolean) + : []; + + for (const typeName of normalizedTypes) { + if (LODGING_TYPE_MAP[typeName]) { + return LODGING_TYPE_MAP[typeName]; + } + } + + for (const typeName of normalizedTypes) { + if (VALID_TYPES.has(typeName)) { + return typeName; + } + } + + return 'other'; +} diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 50d524e2..09648fad 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -3,10 +3,10 @@ "about": "Über", "close": "Schließen", "license": "Lizenziert unter der GPL-3.0-Lizenz.", - "message": "Hergestellt mit ❤️ in den Vereinigten Staaten.", + "message": "Hergestellt mit ❤️ in den Vereinigten Staaten von Amerika.", "nominatim_1": "Standortsuche und Geokodierung werden bereitgestellt von", "other_attributions": "Weitere Hinweise finden Sie in der README-Datei.", - "generic_attributions": "Melden Sie sich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen.", + "generic_attributions": "Melde dich bei Adventurelog an, um Zuschreibungen für aktivierte Integrationen und Dienste anzuzeigen.", "attributions": "Zuschreibungen", "developer": "Entwickler", "license_info": "Lizenz", @@ -31,14 +31,14 @@ "clear": "Zurücksetzen", "collection": "Sammlung", "date": "Datum", - "dates": "Termine", + "dates": "Daten", "delete_collection": "Sammlung löschen", "delete_collection_success": "Sammlung erfolgreich gelöscht!", "descending": "Absteigend", "duration": "Dauer", "edit_collection": "Sammlung bearbeiten", "filter": "Filter", - "homepage": "Startseite", + "homepage": "Homepage", "image_upload_error": "Fehler beim Hochladen des Bildes", "image_upload_success": "Bild erfolgreich hochgeladen!", "latitude": "Breitengrad", @@ -55,7 +55,7 @@ "share": "Teilen", "sort": "Sortieren", "sources": "Quellen", - "start_before_end_error": "Das Start- muss vor dem Enddatum liegen", + "start_before_end_error": "Das Startdatum muss vor dem Enddatum liegen", "unarchive": "Dearchivieren", "unarchived_collection_message": "Sammlung erfolgreich dearchiviert!", "updated": "Aktualisiert", @@ -68,14 +68,14 @@ "category": "Kategorie", "copy_link": "Link kopieren", "create_new": "Neu erstellen", - "date_constrain": "Beschränke auf Sammlungstermine", + "date_constrain": "Beschränke auf Sammlungsdaten", "description": "Beschreibung", "end_date": "Enddatum", "fetch_image": "Bild abrufen", "generate_desc": "Beschreibung generieren", "image_fetch_failed": "Bild konnte nicht abgerufen werden", "link": "Link", - "location": "Standort", + "location": "Ort", "no_results": "Keine Ergebnisse gefunden", "remove": "Entfernen", "search_for_location": "Nach einem Ort suchen", @@ -97,8 +97,8 @@ "links": "Links", "note": "Notiz", "notes": "Notizen", - "transportation": "Transport", - "transportations": "Transporte", + "transportation": "Transportmittel", + "transportations": "Transportmittel", "day": "Tag", "add_a_tag": "Fügen Sie ein Schlagwort hinzu", "tags": "Schlagworte", @@ -111,14 +111,14 @@ "date_information": "Datumsinformationen", "delete_checklist": "Checkliste löschen", "delete_note": "Notiz löschen", - "delete_transportation": "Transport löschen", + "delete_transportation": "Transportart löschen", "end": "Ende", "from": "Von", "note_delete_confirm": "Sind Sie sicher, dass Sie diese Notiz löschen möchten? \nDies kann nicht rückgängig gemacht werden!", "out_of_range": "Außerhalb des geplanten Reisezeitraums", "start": "Start", "to": "Nach", - "transportation_delete_confirm": "Sind Sie sicher, dass Sie diesen Transport löschen möchten? \nDies lässt sich nicht rückgängig machen.", + "transportation_delete_confirm": "Sind Sie sicher, dass Sie diese Transportart löschen möchten? \nDies lässt sich nicht rückgängig machen.", "cities_updated": "Städte aktualisiert", "attachment": "Anhang", "attachment_delete_success": "Anhang erfolgreich gelöscht!", @@ -137,14 +137,14 @@ "lodging_delete_confirm": "Sind Sie sicher, dass Sie diese Unterkunft löschen möchten? \nDies lässt sich nicht rückgängig machen!", "price": "Preis", "open_in_maps": "In Karten öffnen", - "all_day": "Ganztags", - "collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden Reiseroutenplanungsfunktionen auf der Sammlungsseite freigegeben.", + "all_day": "ganzer Tag", + "collection_no_start_end_date": "Durch das Hinzufügen eines Start- und Enddatums zur Sammlung werden auf der Sammlungsseite Funktionen zur Reiseplanung freigeschaltet.", "invalid_date_range": "Ungültiger Datumsbereich", "timezone": "Zeitzone", "no_visits": "Keine Besuche", "coordinates": "Koordinaten", "copy_coordinates": "Koordinaten kopieren", - "sun_times": "Sonnenzeiten", + "sun_times": "Sonnenstunden", "sunrise": "Sonnenaufgang", "sunset": "Sonnenuntergang", "timed": "Zeitlich abgestimmt", @@ -157,30 +157,30 @@ "filters_and_stats": "Filter & Statistiken", "no_adventures_message": "Dokumentieren Sie Ihre Abenteuer und planen Sie neue. \nJede Reise hat eine Geschichte, die es wert ist, erzählt zu werden.", "travel_progress": "Reisefortschritt", - "collections_linked": "Kollektionen verknüpft", + "collections_linked": "Sammlungen verknüpft", "create_collection_first": "Erstellen Sie zuerst eine Sammlung, um Ihre Abenteuer und Erinnerungen zu organisieren.", "delete_collection_warning": "Sind Sie sicher, dass Sie diese Sammlung löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.", "done": "Erledigt", "name_location": "Name, Ort", "check_in": "Einchecken", "check_out": "Auschecken", - "collection_link_location_error": "Fehler beim Verknüpfen des Standorts mit der Sammlung", - "collection_link_location_success": "Standort mit der Sammlung erfolgreich verknüpft!", - "collection_locations": "Sammelorte einbeziehen", - "collection_remove_location_error": "Fehler bei der Entfernung des Standorts aus der Sammlung", - "collection_remove_location_success": "Standort erfolgreich aus der Sammlung entfernt!", - "create_location": "Standort erstellen", - "delete_location": "Standort löschen", - "edit_location": "Standort bearbeiten", + "collection_link_location_error": "Fehler beim Verknüpfen des Ortes mit der Sammlung", + "collection_link_location_success": "Ort mit der Sammlung erfolgreich verknüpft!", + "collection_locations": "Orte in Sammlung einbeziehen", + "collection_remove_location_error": "Fehler bei der Entfernung des Ortes aus der Sammlung", + "collection_remove_location_success": "Ort erfolgreich aus der Sammlung entfernt!", + "create_location": "Ort erstellen", + "delete_location": "Ort löschen", + "edit_location": "Ort bearbeiten", "location_delete_confirm": "Sind Sie sicher, dass Sie diesen Ort löschen möchten? \nDiese Aktion kann nicht rückgängig gemacht werden.", "location_delete_success": "Standort erfolgreich gelöscht!", "location_not_found": "Ort nicht gefunden", "location_not_found_desc": "Der Ort, den Sie gesucht haben, konnte nicht gefunden werden. \nBitte probieren Sie einen anderen Ort aus oder schauen Sie später noch einmal vorbei.", - "new_location": "Neuer Standort", - "no_collections_to_add_location": "Keine Sammlungen gefunden, die dieser Ort hinzugefügt werden kann.", - "public_location": "Öffentliche Lage", + "new_location": "Neuer Ort", + "no_collections_to_add_location": "Keine Sammlungen gefunden, der dieser Ort hinzugefügt werden kann.", + "public_location": "Öffentlicher Ort", "visit_calendar": "Besuchs-Kalender", - "no_locations_found": "Keine Standorte gefunden", + "no_locations_found": "Keine Orte gefunden", "image_modal_navigate": "Verwenden Sie Pfeiltasten oder klicken Sie, um zu navigieren", "details": "Details", "leave": "Verlassen", @@ -277,7 +277,7 @@ "rest_time": "Ruhezeit", "saved_activities": "Gespeicherte Aktivitäten", "search_location": "Suche nach einem Ort", - "search_placeholder": "Stadt, Standort oder Wahrzeichen eingeben ...", + "search_placeholder": "Stadt, Ort oder Wahrzeichen eingeben ...", "search_trails_placeholder": "Trails nach Namen suchen", "searching": "Suche", "select_on_map": "Wählen Sie auf der Karte", @@ -364,9 +364,9 @@ "geographic_breakdown": "Geografische Aufteilung", "gpx_routes": "GPX-Routen", "hide_filters": "Filter ausblenden", - "images_captured": "Bilder aufgenommen", + "images_captured": "aufgenommene Fotos", "in": "in", - "in_progress": "Im Gange", + "in_progress": "Laufend", "items": "Einträge", "itinerary_link_modal": { "add_here": "Hier hinzufügen", @@ -438,12 +438,13 @@ "import_from_file": "Aus Datei importieren", "import_success": "Erfolg importieren", "duplicate": "Duplizieren", - "duplicate_location": "Standort duplizieren", - "location_duplicate_success": "Standort erfolgreich dupliziert!", - "location_duplicate_error": "Standort konnte nicht dupliziert werden.", + "duplicate_location": "Ort duplizieren", + "location_duplicate_success": "Ort erfolgreich dupliziert!", + "location_duplicate_error": "Ort konnte nicht dupliziert werden.", "location_actions": "Standort-Aktionen", "collection_duplicate_success": "Sammlung erfolgreich dupliziert! Weiterleitung...", - "collection_duplicate_error": "Sammlung konnte nicht dupliziert werden." + "collection_duplicate_error": "Sammlung konnte nicht dupliziert werden.", + "add_details": "Details hinzufügen" }, "home": { "desc_1": "Entdecken, planen und erkunden Sie mühelos", @@ -456,7 +457,7 @@ "go_to": "AdventureLog öffnen", "hero_1": "Entdecken Sie die aufregendsten Abenteuer der Welt", "hero_2": "Entdecken und planen Sie Ihr nächstes Abenteuer mit AdventureLog. Erkunden Sie atemberaubende Reiseziele, erstellen Sie individuelle Reisepläne und bleiben Sie unterwegs stets verbunden.", - "key_features": "Hauptmerkmale", + "key_features": "Hauptfunktionen", "feature_2_desc": "Erstellen Sie mühelos individuelle Reisepläne und erhalten Sie eine detaillierte Tagesübersicht Ihrer Reise.", "explore_world": "Welt erkunden", "latest_travel_experiences": "Ihre neuesten Reiseerlebnisse", @@ -480,7 +481,7 @@ "aestheticLight": "Ästhetisch Hell", "aqua": "Aqua", "dark": "Dunkel", - "dim": "Düster", + "dim": "Gedämpft", "forest": "Wald", "light": "Hell", "night": "Nacht", @@ -493,7 +494,7 @@ "admin_panel": "Administration", "navigation": "Navigation", "worldtravel": "Weltreisen", - "mobile_login": "Mobile Anmeldung" + "mobile_login": "Handy Anmeldung" }, "auth": { "confirm_password": "Passwort bestätigen", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index a3d37482..2ce342a6 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -494,7 +494,8 @@ "export_success": "Exported collection", "location_actions": "Location actions", "collection_duplicate_success": "Collection duplicated successfully! Redirecting...", - "collection_duplicate_error": "Failed to duplicate collection." + "collection_duplicate_error": "Failed to duplicate collection.", + "add_details": "Add details" }, "worldtravel": { "country_list": "Country List", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 8a4f9c0b..dca2ed4e 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -77,7 +77,7 @@ "longitude": "Longitud", "visit": "Visita", "visits": "Visitas", - "create_new": "Crear nuevo...", + "create_new": "Crear nuevo", "ascending": "Ascendente", "date": "Fecha", "descending": "Descendente", @@ -423,11 +423,11 @@ "already_added": "Ya agregado", "already_added_on_this_day": "Ya agregado en este día", "already_added_on_this_day_desc": "Estos artículos ya están programados para este día.", - "already_added_other_days": "Ya agregado en otros días.", - "already_added_other_days_desc": "Estos artículos están programados en diferentes fechas. \nAgregarlos aquí actualizará su fecha o los agregará tal cual.", + "already_added_other_days": "Ya agregado en otros días", + "already_added_other_days_desc": "Estos artículos están programados en diferentes fechas. Agregarlos aquí actualizará su fecha o los agregará tal cual.", "items_available": "{count} elementos disponibles para vincular", "items_on_other_days": "Artículos de otros días", - "items_on_other_days_desc": "Estos artículos tienen diferentes fechas. \nPuede agregarlos y, opcionalmente, actualizar su fecha para que coincida.", + "items_on_other_days_desc": "Estos artículos tienen diferentes fechas. Puede agregarlos y, opcionalmente, actualizar su fecha para que coincida.", "items_on_this_day": "Artículos en este día", "no_unscheduled_items": "No hay artículos no programados disponibles", "no_unscheduled_items_desc": "Todos los artículos se han agregado al itinerario o no hay artículos para agregar.", @@ -493,7 +493,8 @@ "collection_duplicate_success": "Colección duplicada con éxito. Redirigiendo...", "collection_duplicate_error": "No se pudo duplicar la colección.", "hide_strava_activities": "Ocultar actividades de Strava", - "show_strava_activities": "Mostrar actividades de Strava" + "show_strava_activities": "Mostrar actividades de Strava", + "add_details": "Añadir detalles" }, "worldtravel": { "no_countries_found": "No se encontraron países", @@ -549,7 +550,7 @@ "in": "en", "loading_globe_spin": "Cargando giro global", "no_globe_spin_data": "Sin datos de giro de globo", - "show_globe_spin": "Show Globe Spin", + "show_globe_spin": "Mostrar Giro de Globo", "spin_again": "Girar de nuevo", "spinning_globe": "Globo hilado", "try_again": "Intentar otra vez", @@ -584,7 +585,7 @@ "enter_username": "Ingrese su nombre de usuario", "logging_in": "Iniciar sesión", "totp": "Código de dos factores", - "user_email_verification_required": "Se requiere verificación por correo electrónico. \nPor favor revise su correo electrónico para obtener un enlace de verificación." + "user_email_verification_required": "Se requiere verificación por correo electrónico. Revise su correo electrónico para obtener un enlace de verificación." }, "users": { "no_users_found": "No se encontraron usuarios con perfiles públicos." @@ -606,7 +607,7 @@ "reset_password": "Restablecer contraseña", "about_this_background": "Sobre este fondo", "join_discord": "Únete a Discord", - "join_discord_desc": "para compartir tus propias fotos. Publícalos en el canal de #travel-share", + "join_discord_desc": "para compartir tus propias fotos. Publícalos en el canal de #travel-share.", "photo_by": "Foto por", "current_password": "Contraseña actual", "password_change_lopout_warning": "Se cerrará su sesión después de cambiar su contraseña.", @@ -777,7 +778,7 @@ "available": "Disponible", "linked": "Vinculado", "try_different_search": "Pruebe una búsqueda o filtro diferente.", - "changing_date_title": "El cambio de fechas afectará los elementos del itinerario.", + "changing_date_title": "El cambio de fechas afectará los elementos del itinerario", "changing_date_warning": "Cualquier elemento del itinerario fuera del nuevo rango de fechas se eliminará del itinerario y se volverá a colocar en los elementos sin fecha de la colección.", "clear_cover": "cubierta transparente", "collaborators": "Colaboradores", @@ -1010,7 +1011,7 @@ "enter_reservation_number": "Ingrese el número de reserva", "update_lodging_details": "Actualizar detalles de alojamiento", "invalid_link": "Introduzca una URL válida (por ejemplo, https://example.com).", - "save_failed": "No se pudo guardar el alojamiento. \nPor favor inténtalo de nuevo." + "save_failed": "No se pudo guardar el alojamiento. Inténtalo de nuevo." }, "google_maps": { "google_maps_integration_desc": "Conecte su cuenta de Google Maps para obtener resultados y recomendaciones de búsqueda de ubicación de alta calidad.", @@ -1070,7 +1071,7 @@ "currencies": "Monedas", "currency": "Divisa", "event_timezone": "Zona horaria del evento", - "event_timezone_desc": "La zona horaria del evento utiliza la zona horaria de la ubicación o del elemento cuando esté disponible. \nMi zona horaria usa", + "event_timezone_desc": "La zona horaria del evento utiliza la zona horaria de la ubicación o del elemento cuando esté disponible. Mi zona horaria usa", "events": "eventos", "local_timezone": "mi zona horaria", "no_calendar_events": "Aún no hay visitas programadas para esta colección.", @@ -1113,16 +1114,16 @@ "add_description": "Agregar descripción", "add_to_day": "Añadir al día", "add_to_trip_context": "Agregar contexto de viaje", - "added_to_trip_context": "Agregado al contexto del viaje.", + "added_to_trip_context": "Agregado al contexto del viaje", "auto_generate": "Generar automáticamente", "auto_generate_itinerary": "Itinerario generado automáticamente", - "auto_generate_itinerary_desc": "Esta colección tiene elementos fechados pero aún no tiene un itinerario. \n¿Quieres organizarlos automáticamente por fecha?", + "auto_generate_itinerary_desc": "Esta colección tiene elementos fechados pero aún no tiene un itinerario. ¿Quieres organizarlos automáticamente por fecha?", "change_day": "Cambiar día", "drag_to_reorder": "Arrastra para reordenar", "failed_to_add_to_trip_context": "No se pudo agregar el elemento al contexto del viaje", "failed_to_move_to_trip_context": "No se pudo pasar al contexto del viaje", "generating": "generando", - "item_already_in_trip_context": "Elementos que ya están en el contexto del viaje.", + "item_already_in_trip_context": "Elementos que ya están en el contexto del viaje", "item_not_found": "Artículo no encontrado", "item_remove_error": "Error al eliminar artículo del itinerario", "item_remove_success": "Artículo eliminado del itinerario", @@ -1131,7 +1132,7 @@ "moved_to_trip_context": "Movido al contexto del viaje", "multi_day": "Varios días", "no_itinerary_yet": "Aún no hay itinerario", - "no_plans_for_day": "No hay planes para este día.", + "no_plans_for_day": "No hay planes para este día", "no_trip_context_items": "Aún no hay elementos de contexto de viaje.", "remove_from_itinerary": "Quitar del día", "remove_from_trip_context": "Eliminar del contexto", @@ -1148,7 +1149,7 @@ "create": "Crear clave", "create_error": "No se pudo crear la clave API.", "created": "Creado", - "description": "Cree claves API personales para acceso programático. \nLas claves se muestran solo una vez en el momento de la creación.", + "description": "Cree claves API personales para acceso programático. Las claves se muestran solo una vez en el momento de la creación.", "dismiss": "Despedir", "key_created": "Clave API creada correctamente.", "key_name_placeholder": "Nombre clave (por ejemplo, Home Assistant)", @@ -1156,7 +1157,7 @@ "last_used": "último usado", "never_used": "Nunca usado", "new_key_title": "Guarde su nueva clave API", - "new_key_warning": "Esta clave no se volverá a mostrar. \nCópialo y guárdalo en un lugar seguro.", + "new_key_warning": "Esta clave no se volverá a mostrar. Cópialo y guárdalo en un lugar seguro.", "no_keys": "Aún no hay claves API.", "revoke": "Revocar", "revoke_error": "No se pudo revocar la clave API.", 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?" } } diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json index 41dfe541..3b5fe1b7 100644 --- a/frontend/src/locales/sv.json +++ b/frontend/src/locales/sv.json @@ -443,7 +443,8 @@ "collection_duplicate_success": "Samlingen har duplicerats! Omdirigerar...", "collection_duplicate_error": "Misslyckades med att duplicera samlingen.", "hide_strava_activities": "Göm Strava-aktiviteter", - "show_strava_activities": "Visa Strava-aktiviteter" + "show_strava_activities": "Visa Strava-aktiviteter", + "add_details": "Lägg till detaljer" }, "home": { "desc_1": "Upptäck, planera och utforska med lätthet", diff --git a/frontend/src/locales/tr.json b/frontend/src/locales/tr.json index 3c0f2886..532099f8 100644 --- a/frontend/src/locales/tr.json +++ b/frontend/src/locales/tr.json @@ -493,7 +493,8 @@ "collection_duplicate_success": "Koleksiyon başarıyla kopyalandı! Yönlendiriliyor...", "collection_duplicate_error": "Koleksiyon kopyalanamadı.", "hide_strava_activities": "Strava Faaliyetlerini Gizle", - "show_strava_activities": "Strava Aktivitelerini Göster" + "show_strava_activities": "Strava Aktivitelerini Göster", + "add_details": "Detayları ekle" }, "worldtravel": { "country_list": "Ülke Listesi", @@ -1163,7 +1164,7 @@ "title": "API Anahtarları", "copy_error": "Anahtar kopyalanırken hata oluştu.", "usage_middle": "başlık veya olarak", - "usage_prefix": "Bu anahtarı şu şekilde kullanın:", + "usage_prefix": "Bu anahtar içinde kullanın", "delete_confirm": "Bu mobil API anahtarını silmek istediğinizden emin misiniz?" } } diff --git a/frontend/src/routes/+page.server.ts b/frontend/src/routes/+page.server.ts index 6182a1a0..348665d6 100644 --- a/frontend/src/routes/+page.server.ts +++ b/frontend/src/routes/+page.server.ts @@ -29,7 +29,8 @@ export const actions: Actions = { cookies.set('colortheme', theme, { path: '/', maxAge: 60 * 60 * 24 * 365, // 1 year - sameSite: 'lax' + sameSite: 'lax', + secure: url.protocol === 'https:' }); } }, diff --git a/frontend/src/routes/locations/[id]/+page.svelte b/frontend/src/routes/locations/[id]/+page.svelte index 28539a54..5ce4de88 100644 --- a/frontend/src/routes/locations/[id]/+page.svelte +++ b/frontend/src/routes/locations/[id]/+page.svelte @@ -25,6 +25,7 @@ import NewLocationModal from '$lib/components/locations/LocationModal.svelte'; import CashMultiple from '~icons/mdi/cash-multiple'; import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money'; + import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte'; let geojson: any; @@ -675,32 +676,12 @@ {/if} - +
diff --git a/frontend/src/routes/lodging/[id]/+page.svelte b/frontend/src/routes/lodging/[id]/+page.svelte index bcfc6faf..f198a42d 100644 --- a/frontend/src/routes/lodging/[id]/+page.svelte +++ b/frontend/src/routes/lodging/[id]/+page.svelte @@ -28,6 +28,7 @@ import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils'; import LodgingModal from '$lib/components/lodging/LodgingModal.svelte'; import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money'; + import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte'; const renderMarkdown = (markdown: string) => { return marked(markdown) as string; @@ -417,38 +418,12 @@
{#if lodging.location} -
-

- - {lodging.location} -

- -
+ {/if} diff --git a/frontend/src/routes/transportations/[id]/+page.svelte b/frontend/src/routes/transportations/[id]/+page.svelte index 0e42fa62..7eb3f444 100644 --- a/frontend/src/routes/transportations/[id]/+page.svelte +++ b/frontend/src/routes/transportations/[id]/+page.svelte @@ -26,6 +26,7 @@ import TransportationModal from '$lib/components/transportation/TransportationModal.svelte'; import CashMultiple from '~icons/mdi/cash-multiple'; import { DEFAULT_CURRENCY, formatMoney, toMoneyValue } from '$lib/money'; + import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte'; const renderMarkdown = (markdown: string) => { return marked(markdown) as string; @@ -637,32 +638,7 @@ {transportation.from_location}

- + {/if} @@ -672,32 +648,7 @@ {transportation.to_location}

- + {/if}