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['namedetails'] = '1'
78 queryparts['extratags'] = '1'
79 if parsed.geometry_simplification > 0.0:
80 queryparts['polygon_threshold'] = f"{parsed.geometry_simplification:.6g}"
81 if parsed.max_results != 10:
82 queryparts['limit'] = str(parsed.max_results)
84 queryparts['countrycodes'] = ','.join(parsed.countries)
85 queryparts['exclude_place_ids'] = \
86 ','.join(chain(excluded, map(str, (e for e in parsed.excluded if e > 0))))
88 queryparts['viewbox'] = ','.join(f"{c:.7g}" for c in parsed.viewbox.coords)
89 if parsed.bounded_viewbox:
90 queryparts['bounded'] = '1'
91 if not details['dedupe']:
92 queryparts['dedupe'] = '0'
93 if feature_type in FEATURE_TYPE_TO_RANK:
94 queryparts['featureType'] = feature_type
97 def deduplicate_results(results: SearchResults, max_results: int) -> SearchResults:
98 """ Remove results that look like duplicates.
100 Two results are considered the same if they have the same OSM ID
101 or if they have the same category, display name and rank.
104 classification_done = set()
105 deduped = SearchResults()
106 for result in results:
107 if result.source_table == SourceTable.POSTCODE:
108 assert result.names and 'ref' in result.names
109 if any(_is_postcode_relation_for(r, result.names['ref']) for r in results):
111 if result.source_table == SourceTable.PLACEX:
112 classification = (result.osm_object[0] if result.osm_object else None,
116 if result.osm_object not in osm_ids_done \
117 and classification not in classification_done:
118 deduped.append(result)
119 osm_ids_done.add(result.osm_object)
120 classification_done.add(classification)
122 deduped.append(result)
123 if len(deduped) >= max_results:
129 def _is_postcode_relation_for(result: SearchResult, postcode: str) -> bool:
130 return result.source_table == SourceTable.PLACEX \
131 and result.osm_object is not None \
132 and result.osm_object[0] == 'R' \
133 and result.category == ('boundary', 'postal_code') \
134 and result.names is not None \
135 and result.names.get('ref') == postcode
138 def _deg(axis: str) -> str:
139 return f"(?P<{axis}_deg>\\d+\\.\\d+)°?"
142 def _deg_min(axis: str) -> str:
143 return f"(?P<{axis}_deg>\\d+)[°\\s]+(?P<{axis}_min>[\\d.]+)[′']*"
146 def _deg_min_sec(axis: str) -> str:
147 return f"(?P<{axis}_deg>\\d+)[°\\s]+(?P<{axis}_min>\\d+)[′'\\s]+(?P<{axis}_sec>[\\d.]+)[\"″]*"
150 COORD_REGEX = [re.compile(r'(?:(?P<pre>.*?)\s+)??' + r + r'(?:\s+(?P<post>.*))?') for r in (
151 r"(?P<ns>[NS])\s*" + _deg('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg('lon'),
152 _deg('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg('lon') + r"\s*(?P<ew>[EW])",
153 r"(?P<ns>[NS])\s*" + _deg_min('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg_min('lon'),
154 _deg_min('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg_min('lon') + r"\s*(?P<ew>[EW])",
155 r"(?P<ns>[NS])\s*" + _deg_min_sec('lat') + r"[\s,]+" + r"(?P<ew>[EW])\s*" + _deg_min_sec('lon'),
156 _deg_min_sec('lat') + r"\s*(?P<ns>[NS])[\s,]+" + _deg_min_sec('lon') + r"\s*(?P<ew>[EW])",
157 r"\[?(?P<lat_deg>[+-]?\d+\.\d+)[\s,]+(?P<lon_deg>[+-]?\d+\.\d+)\]?"
161 def extract_coords_from_query(query: str) -> Tuple[str, Optional[float], Optional[float]]:
162 """ Look for something that is formatted like a coordinate at the
163 beginning or end of the query. If found, extract the coordinate and
164 return the remaining query (or the empty string if the query
165 consisted of nothing but a coordinate).
167 Only the first match will be returned.
169 for regex in COORD_REGEX:
170 match = regex.fullmatch(query)
173 groups = match.groupdict()
174 if not groups['pre'] or not groups['post']:
175 x = float(groups['lon_deg']) \
176 + float(groups.get('lon_min', 0.0)) / 60.0 \
177 + float(groups.get('lon_sec', 0.0)) / 3600.0
178 if groups.get('ew') == 'W':
180 y = float(groups['lat_deg']) \
181 + float(groups.get('lat_min', 0.0)) / 60.0 \
182 + float(groups.get('lat_sec', 0.0)) / 3600.0
183 if groups.get('ns') == 'S':
185 return groups['pre'] or groups['post'] or '', x, y
187 return query, None, None
190 CATEGORY_REGEX = re.compile(r'(?P<pre>.*?)\[(?P<cls>[a-zA-Z_]+)=(?P<typ>[a-zA-Z_]+)\](?P<post>.*)')
193 def extract_category_from_query(query: str) -> Tuple[str, Optional[str], Optional[str]]:
194 """ Extract a hidden category specification of the form '[key=value]' from
195 the query. If found, extract key and value and
196 return the remaining query (or the empty string if the query
197 consisted of nothing but a category).
199 Only the first match will be returned.
201 match = CATEGORY_REGEX.search(query)
202 if match is not None:
203 return (match.group('pre').strip() + ' ' + match.group('post').strip()).strip(), \
204 match.group('cls'), match.group('typ')
206 return query, None, None