""" URL state encoding/decoding for shareable search URLs. Core principle: All searches are represented uniformly - basic search is just advanced search with a single text query at weight +1. """ import base64 import json from typing import Dict, List, Optional, Tuple, Any try: from flask import request except ImportError: request = None def encode_search_state( text_queries: List[str], text_weights: List[float], image_queries: List[Tuple[float, float, float]], # (ra, dec, fov) image_weights: List[float], rmag_min: float = 13.0, rmag_max: float = 20.0, expand_galaxy: Optional[str] = None ) -> str: """ Encode search state into a compact base64 string. Args: text_queries: List of text query strings text_weights: List of weights for text queries image_queries: List of (ra, dec, fov) tuples image_weights: List of weights for image queries rmag_min: Minimum r-band magnitude (default 13.0) rmag_max: Maximum r-band magnitude (default 20.0) expand_galaxy: Optional object_id to expand in modal after search Returns: URL-safe base64 encoded string """ state = {} # Only include non-empty query arrays if text_queries: state["tq"] = text_queries state["tw"] = text_weights if image_queries: state["iq"] = image_queries state["iw"] = image_weights # Only include magnitude limits if they differ from defaults if rmag_min != 13.0: state["rmin"] = rmag_min if rmag_max != 20.0: state["rmax"] = rmag_max # Include expand galaxy if specified if expand_galaxy: state["exp"] = expand_galaxy # Encode to JSON then base64 (URL-safe) json_str = json.dumps(state, separators=(',', ':')) # Compact JSON encoded = base64.urlsafe_b64encode(json_str.encode('utf-8')).decode('utf-8') # Remove padding (= characters) to make URL shorter - we'll add back on decode return encoded.rstrip('=') def decode_search_state(encoded: str) -> Dict[str, Any]: """ Decode a base64-encoded search state string. Args: encoded: URL-safe base64 encoded string Returns: Dictionary with full keys: { 'text_queries': [...], 'text_weights': [...], 'image_queries': [...], 'image_weights': [...], 'rmag_min': float, 'rmag_max': float, 'expand_galaxy': str or None } Returns empty/default state if decoding fails. """ # Default/empty state default_state = { 'text_queries': [], 'text_weights': [], 'image_queries': [], 'image_weights': [], 'rmag_min': 13.0, 'rmag_max': 20.0, 'expand_galaxy': None } if not encoded: return default_state try: # Add back padding if needed padding = 4 - (len(encoded) % 4) if padding != 4: encoded += '=' * padding # Decode from base64 then JSON json_str = base64.urlsafe_b64decode(encoded.encode('utf-8')).decode('utf-8') compact_state = json.loads(json_str) # Expand compact keys to full keys state = { 'text_queries': compact_state.get('tq', []), 'text_weights': compact_state.get('tw', []), 'image_queries': compact_state.get('iq', []), 'image_weights': compact_state.get('iw', []), 'rmag_min': compact_state.get('rmin', 13.0), 'rmag_max': compact_state.get('rmax', 20.0), 'expand_galaxy': compact_state.get('exp', None) } return state except (ValueError, KeyError, json.JSONDecodeError, Exception): # Silently fail and return empty state for any decode errors return default_state def build_share_url( text_queries: List[str], text_weights: List[float], image_queries: List[Tuple[float, float, float]], image_weights: List[float], rmag_min: float = 13.0, rmag_max: float = 20.0, expand_galaxy: Optional[str] = None ) -> str: """ Build a complete shareable URL for the current search state. Args: Same as encode_search_state() Returns: Complete URL like: https://app.com?s= """ from src.config import URL_STATE_PARAM # Get base URL from Flask request context try: if request and hasattr(request, 'host_url'): base_url = request.host_url.rstrip('/') else: base_url = "http://localhost:8050" except (RuntimeError, AttributeError): # If not in request context (e.g., during testing), use a placeholder base_url = "http://localhost:8050" # Encode the state encoded = encode_search_state( text_queries=text_queries, text_weights=text_weights, image_queries=image_queries, image_weights=image_weights, rmag_min=rmag_min, rmag_max=rmag_max, expand_galaxy=expand_galaxy ) return f"{base_url}?{URL_STATE_PARAM}={encoded}" def parse_url_search_param(search_string: str) -> Dict[str, Any]: """ Parse the search parameter from a URL query string. Args: search_string: The URL search/query string (e.g., "?s=eyJxdWVy...") Returns: Decoded state dictionary (same format as decode_search_state) """ from src.config import URL_STATE_PARAM if not search_string: return decode_search_state("") # Remove leading '?' if present search_string = search_string.lstrip('?') # Parse query parameters params = {} for param in search_string.split('&'): if '=' in param: key, value = param.split('=', 1) params[key] = value # Get the state parameter encoded = params.get(URL_STATE_PARAM, '') return decode_search_state(encoded)