1 # SPDX-License-Identifier: GPL-3.0-or-later
 
   3 # This file is part of Nominatim. (https://nominatim.org)
 
   5 # Copyright (C) 2024 by the Nominatim developer community.
 
   6 # For a full list of authors see the git log.
 
   8 Helper function for parsing parameters and and outputting data
 
   9 specifically for the v1 version of the API.
 
  11 from typing import Tuple, Optional, Any, Dict, Iterable
 
  12 from itertools import chain
 
  15 from ..results import SearchResult, SearchResults, SourceTable
 
  16 from ..types import SearchDetails, GeometryFormat
 
  19 REVERSE_MAX_RANKS = [2, 2, 2,   # 0-2   Continent/Sea
 
  26                      19,        # 13    Village/Suburb
 
  27                      22,        # 14    Hamlet/Neighbourhood
 
  29                      26,        # 16    Major Streets
 
  30                      27,        # 17    Minor Streets
 
  35 def zoom_to_rank(zoom: int) -> int:
 
  36     """ Convert a zoom parameter into a rank according to the v1 API spec.
 
  38     return REVERSE_MAX_RANKS[max(0, min(18, zoom))]
 
  41 FEATURE_TYPE_TO_RANK: Dict[Optional[str], Tuple[int, int]] = {
 
  49 def feature_type_to_rank(feature_type: Optional[str]) -> Tuple[int, int]:
 
  50     """ Convert a feature type parameter to a tuple of
 
  51         feature type name, minimum rank and maximum rank.
 
  53     return FEATURE_TYPE_TO_RANK.get(feature_type, (0, 30))
 
  56 def extend_query_parts(queryparts: Dict[str, Any], details: Dict[str, Any],
 
  57                        feature_type: Optional[str],
 
  58                        namedetails: bool, extratags: bool,
 
  59                        excluded: Iterable[str]) -> None:
 
  60     """ Add parameters from details dictionary to the query parts
 
  61         dictionary which is suitable as URL parameter dictionary.
 
  63     parsed = SearchDetails.from_kwargs(details)
 
  64     if parsed.geometry_output != GeometryFormat.NONE:
 
  65         if GeometryFormat.GEOJSON & parsed.geometry_output:
 
  66             queryparts['polygon_geojson'] = '1'
 
  67         if GeometryFormat.KML & parsed.geometry_output:
 
  68             queryparts['polygon_kml'] = '1'
 
  69         if GeometryFormat.SVG & parsed.geometry_output:
 
  70             queryparts['polygon_svg'] = '1'
 
  71         if GeometryFormat.TEXT & parsed.geometry_output:
 
  72             queryparts['polygon_text'] = '1'
 
  73     if parsed.address_details:
 
  74         queryparts['addressdetails'] = '1'
 
  76         queryparts['entrances'] = '1'
 
  78         queryparts['namedetails'] = '1'
 
  80         queryparts['extratags'] = '1'
 
  81     if parsed.geometry_simplification > 0.0:
 
  82         queryparts['polygon_threshold'] = f"{parsed.geometry_simplification:.6g}"
 
  83     if parsed.max_results != 10:
 
  84         queryparts['limit'] = str(parsed.max_results)
 
  86         queryparts['countrycodes'] = ','.join(parsed.countries)
 
  87     queryparts['exclude_place_ids'] = \
 
  88         ','.join(chain(excluded, map(str, (e for e in parsed.excluded if e > 0))))
 
  90         queryparts['viewbox'] = ','.join(f"{c:.7g}" for c in parsed.viewbox.coords)
 
  91     if parsed.bounded_viewbox:
 
  92         queryparts['bounded'] = '1'
 
  93     if not details['dedupe']:
 
  94         queryparts['dedupe'] = '0'
 
  95     if feature_type in FEATURE_TYPE_TO_RANK:
 
  96         queryparts['featureType'] = feature_type
 
  99 def deduplicate_results(results: SearchResults, max_results: int) -> SearchResults:
 
 100     """ Remove results that look like duplicates.
 
 102         Two results are considered the same if they have the same OSM ID
 
 103         or if they have the same category, display name and rank.
 
 106     classification_done = set()
 
 107     deduped = SearchResults()
 
 108     for result in results:
 
 109         if result.source_table == SourceTable.POSTCODE:
 
 110             assert result.names and 'ref' in result.names
 
 111             if any(_is_postcode_relation_for(r, result.names['ref']) for r in results):
 
 113         if result.source_table == SourceTable.PLACEX:
 
 114             classification = (result.osm_object[0] if result.osm_object else None,
 
 118             if result.osm_object not in osm_ids_done \
 
 119                and classification not in classification_done:
 
 120                 deduped.append(result)
 
 121             osm_ids_done.add(result.osm_object)
 
 122             classification_done.add(classification)
 
 124             deduped.append(result)
 
 125         if len(deduped) >= max_results:
 
 131 def _is_postcode_relation_for(result: SearchResult, postcode: str) -> bool:
 
 132     return result.source_table == SourceTable.PLACEX \
 
 133            and result.osm_object is not None \
 
 134            and result.osm_object[0] == 'R' \
 
 135            and result.category == ('boundary', 'postal_code') \
 
 136            and result.names is not None \
 
 137            and result.names.get('ref') == postcode
 
 140 def _deg(axis: str) -> str:
 
 141     return f"(?P<{axis}_deg>\\d+\\.\\d+)°?"
 
 144 def _deg_min(axis: str) -> str:
 
 145     return f"(?P<{axis}_deg>\\d+)[°\\s]+(?P<{axis}_min>[\\d.]+)[′']*"
 
 148 def _deg_min_sec(axis: str) -> str:
 
 149     return f"(?P<{axis}_deg>\\d+)[°\\s]+(?P<{axis}_min>\\d+)[′'\\s]+(?P<{axis}_sec>[\\d.]+)[\"″]*"
 
 152 COORD_REGEX = [re.compile(r'(?:(?P<pre>.*?)\s+)??' + r + r'(?:\s+(?P<post>.*))?') for r in (
 
 153     r"(?P<ns>[NS])\s*" + _deg('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg('lon'),
 
 154     _deg('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg('lon') + r"\s*(?P<ew>[EW])",
 
 155     r"(?P<ns>[NS])\s*" + _deg_min('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg_min('lon'),
 
 156     _deg_min('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg_min('lon') + r"\s*(?P<ew>[EW])",
 
 157     r"(?P<ns>[NS])\s*" + _deg_min_sec('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg_min_sec('lon'),
 
 158     _deg_min_sec('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg_min_sec('lon') + r"\s*(?P<ew>[EW])",
 
 159     r"\[?(?P<lat_deg>[+-]?\d+\.\d+)[\s,]+(?P<lon_deg>[+-]?\d+\.\d+)\]?"
 
 163 def extract_coords_from_query(query: str) -> Tuple[str, Optional[float], Optional[float]]:
 
 164     """ Look for something that is formatted like a coordinate at the
 
 165         beginning or end of the query. If found, extract the coordinate and
 
 166         return the remaining query (or the empty string if the query
 
 167         consisted of nothing but a coordinate).
 
 169         Only the first match will be returned.
 
 171     for regex in COORD_REGEX:
 
 172         match = regex.fullmatch(query)
 
 175         groups = match.groupdict()
 
 176         if not groups['pre'] or not groups['post']:
 
 177             x = float(groups['lon_deg']) \
 
 178                 + float(groups.get('lon_min', 0.0)) / 60.0 \
 
 179                 + float(groups.get('lon_sec', 0.0)) / 3600.0
 
 180             if groups.get('ew') == 'W':
 
 182             y = float(groups['lat_deg']) \
 
 183                 + float(groups.get('lat_min', 0.0)) / 60.0 \
 
 184                 + float(groups.get('lat_sec', 0.0)) / 3600.0
 
 185             if groups.get('ns') == 'S':
 
 187             return groups['pre'] or groups['post'] or '', x, y
 
 189     return query, None, None
 
 192 CATEGORY_REGEX = re.compile(r'(?P<pre>.*?)\[(?P<cls>[a-zA-Z_]+)=(?P<typ>[a-zA-Z_]+)\](?P<post>.*)')
 
 195 def extract_category_from_query(query: str) -> Tuple[str, Optional[str], Optional[str]]:
 
 196     """ Extract a hidden category specification of the form '[key=value]' from
 
 197         the query. If found, extract key and value  and
 
 198         return the remaining query (or the empty string if the query
 
 199         consisted of nothing but a category).
 
 201         Only the first match will be returned.
 
 203     match = CATEGORY_REGEX.search(query)
 
 204     if match is not None:
 
 205         return (match.group('pre').strip() + ' ' + match.group('post').strip()).strip(), \
 
 206                match.group('cls'), match.group('typ')
 
 208     return query, None, None