"""GPX-экспорт публичных GPS-треков (ET-011, ADR-014). Сборка GPX 1.1 из метаданных трека + санитизация имени файла для HTTP Content-Disposition с поддержкой RFC 5987 (UTF-8 filename*). Чистый stdlib-модуль, без I/O — легко тестируется юнитами. """ from __future__ import annotations import urllib.parse import xml.etree.ElementTree as ET from datetime import datetime, timezone # OSM-license URL для блока (ADR-014 §G, ODbL). _OSM_LICENSE_URL = "https://www.openstreetmap.org/copyright" # Запрещённые в FAT/NTFS символы (ADR-014 §F.2). _FORBIDDEN_NAME_CHARS = set('/\\:*?"<>|') # Лимит длины ASCII-fallback по байтам UTF-8 (ADR-014 §F.5; RFC 5987 — 254 # на параметр, минус префикс "filename*=UTF-8''" и расширение). _MAX_NAME_BYTES = 80 def _format_utc(iso_str: str | None) -> str | None: """Нормализует ISO-8601 datetime → 'YYYY-MM-DDTHH:MM:SSZ' (UTC). Поддерживает входные строки с/без таймзоны. None / нераспарсимое — None. """ if not iso_str: return None try: # Python 3.11+ fromisoformat понимает 'Z'-суффикс; для надёжности # делаем явную замену. normalized = iso_str.replace("Z", "+00:00") dt = datetime.fromisoformat(normalized) except (ValueError, TypeError): return None if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) else: dt = dt.astimezone(timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") def build_gpx( *, track_id: int, name: str | None, description: str | None, activity_type: str | None, user: str | None, created_at: str | None, sources: list[str], external_urls: list[str], coords: list[tuple[float, float]], ) -> str: """Собирает GPX 1.1 как XML-строку (с XML-declaration). Args: track_id: id трека (используется только в fallback-имени). name: tracks.name (если пусто — в `` ставится «Без названия»). description: tracks.description (если пусто — `` опускается). activity_type: tracks.activity_type, попадает в ``. user: tracks.user — попадает в ``. created_at: ISO-8601 строка → нормализуется в UTC `