pygpt-net 2.6.52__py3-none-any.whl → 2.6.54__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pygpt_net/CHANGELOG.txt +11 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +4 -0
- pygpt_net/controller/audio/audio.py +22 -1
- pygpt_net/controller/chat/chat.py +5 -1
- pygpt_net/controller/chat/remote_tools.py +116 -0
- pygpt_net/controller/lang/mapping.py +2 -1
- pygpt_net/controller/mode/mode.py +5 -2
- pygpt_net/controller/plugins/plugins.py +29 -3
- pygpt_net/controller/realtime/realtime.py +8 -3
- pygpt_net/controller/ui/mode.py +17 -5
- pygpt_net/core/agents/provider.py +16 -9
- pygpt_net/core/models/models.py +25 -1
- pygpt_net/core/render/web/renderer.py +3 -1
- pygpt_net/data/config/config.json +5 -4
- pygpt_net/data/config/models.json +3 -3
- pygpt_net/data/icons/web_off.svg +1 -0
- pygpt_net/data/icons/web_on.svg +1 -0
- pygpt_net/data/js/app.js +19 -0
- pygpt_net/data/locale/locale.de.ini +1 -0
- pygpt_net/data/locale/locale.en.ini +3 -2
- pygpt_net/data/locale/locale.es.ini +1 -0
- pygpt_net/data/locale/locale.fr.ini +1 -0
- pygpt_net/data/locale/locale.it.ini +1 -0
- pygpt_net/data/locale/locale.pl.ini +1 -4
- pygpt_net/data/locale/locale.uk.ini +1 -0
- pygpt_net/data/locale/locale.zh.ini +1 -0
- pygpt_net/data/locale/plugin.mcp.en.ini +4 -4
- pygpt_net/data/locale/plugin.osm.en.ini +35 -0
- pygpt_net/data/locale/plugin.wolfram.en.ini +24 -0
- pygpt_net/icons.qrc +2 -0
- pygpt_net/icons_rc.py +232 -147
- pygpt_net/js_rc.py +10490 -10432
- pygpt_net/plugin/base/worker.py +7 -1
- pygpt_net/plugin/osm/__init__.py +12 -0
- pygpt_net/plugin/osm/config.py +267 -0
- pygpt_net/plugin/osm/plugin.py +87 -0
- pygpt_net/plugin/osm/worker.py +719 -0
- pygpt_net/plugin/wolfram/__init__.py +12 -0
- pygpt_net/plugin/wolfram/config.py +214 -0
- pygpt_net/plugin/wolfram/plugin.py +115 -0
- pygpt_net/plugin/wolfram/worker.py +551 -0
- pygpt_net/provider/api/anthropic/tools.py +4 -2
- pygpt_net/provider/api/google/__init__.py +3 -2
- pygpt_net/provider/api/google/video.py +0 -0
- pygpt_net/provider/api/openai/agents/experts.py +1 -1
- pygpt_net/provider/api/openai/agents/remote_tools.py +14 -4
- pygpt_net/provider/api/openai/chat.py +7 -2
- pygpt_net/provider/api/openai/remote_tools.py +5 -2
- pygpt_net/provider/api/x_ai/remote.py +6 -1
- pygpt_net/provider/core/config/patch.py +8 -1
- pygpt_net/provider/llms/anthropic.py +29 -1
- pygpt_net/provider/llms/google.py +30 -1
- pygpt_net/provider/llms/open_router.py +3 -1
- pygpt_net/provider/llms/x_ai.py +21 -1
- pygpt_net/ui/layout/chat/output.py +7 -2
- {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/METADATA +37 -2
- {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/RECORD +60 -47
- {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
# ================================================== #
|
|
4
|
+
# This file is a part of PYGPT package #
|
|
5
|
+
# Website: https://pygpt.net #
|
|
6
|
+
# GitHub: https://github.com/szczyglis-dev/py-gpt #
|
|
7
|
+
# MIT License #
|
|
8
|
+
# Created By : Marcin Szczygliński #
|
|
9
|
+
# Updated Date: 2025.09.18 03:25:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
from PySide6.QtCore import Slot
|
|
23
|
+
|
|
24
|
+
from pygpt_net.plugin.base.worker import BaseWorker, BaseSignals
|
|
25
|
+
|
|
26
|
+
Number = Union[int, float]
|
|
27
|
+
LatLon = Tuple[float, float]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WorkerSignals(BaseSignals):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Worker(BaseWorker):
|
|
35
|
+
"""
|
|
36
|
+
OpenStreetMap plugin worker:
|
|
37
|
+
- Geocoding (forward/reverse) via Nominatim
|
|
38
|
+
- Search (alias of geocode)
|
|
39
|
+
- Routing via OSRM (driving/walking/cycling), lightweight by default
|
|
40
|
+
- "Static map": returns openstreetmap.org URL (center/zoom or bbox + optional marker)
|
|
41
|
+
- Utility: OSM site URL, directions URL, single XYZ tile download
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, *args, **kwargs):
|
|
45
|
+
super(Worker, self).__init__()
|
|
46
|
+
self.signals = WorkerSignals()
|
|
47
|
+
self.args = args
|
|
48
|
+
self.kwargs = kwargs
|
|
49
|
+
self.plugin = None
|
|
50
|
+
self.cmds = None
|
|
51
|
+
self.ctx = None
|
|
52
|
+
self.msg = None
|
|
53
|
+
|
|
54
|
+
# ---------------------- Core runner ----------------------
|
|
55
|
+
|
|
56
|
+
@Slot()
|
|
57
|
+
def run(self):
|
|
58
|
+
try:
|
|
59
|
+
responses = []
|
|
60
|
+
for item in self.cmds:
|
|
61
|
+
if self.is_stopped():
|
|
62
|
+
break
|
|
63
|
+
try:
|
|
64
|
+
response = None
|
|
65
|
+
if item["cmd"] in self.plugin.allowed_cmds and self.plugin.has_cmd(item["cmd"]):
|
|
66
|
+
|
|
67
|
+
if item["cmd"] == "osm_geocode":
|
|
68
|
+
response = self.cmd_osm_geocode(item)
|
|
69
|
+
elif item["cmd"] == "osm_reverse":
|
|
70
|
+
response = self.cmd_osm_reverse(item)
|
|
71
|
+
elif item["cmd"] == "osm_search":
|
|
72
|
+
response = self.cmd_osm_search(item)
|
|
73
|
+
elif item["cmd"] == "osm_route":
|
|
74
|
+
response = self.cmd_osm_route(item)
|
|
75
|
+
elif item["cmd"] == "osm_staticmap":
|
|
76
|
+
response = self.cmd_osm_staticmap(item)
|
|
77
|
+
elif item["cmd"] == "osm_bbox_map":
|
|
78
|
+
response = self.cmd_osm_bbox_map(item)
|
|
79
|
+
elif item["cmd"] == "osm_show_url":
|
|
80
|
+
response = self.cmd_osm_show_url(item)
|
|
81
|
+
elif item["cmd"] == "osm_route_url":
|
|
82
|
+
response = self.cmd_osm_route_url(item)
|
|
83
|
+
elif item["cmd"] == "osm_tile":
|
|
84
|
+
response = self.cmd_osm_tile(item)
|
|
85
|
+
|
|
86
|
+
if response is not None:
|
|
87
|
+
responses.append(response)
|
|
88
|
+
|
|
89
|
+
except Exception as e:
|
|
90
|
+
responses.append(self.make_response(item, self.throw_error(e)))
|
|
91
|
+
|
|
92
|
+
if responses:
|
|
93
|
+
self.reply_more(responses)
|
|
94
|
+
if self.msg is not None:
|
|
95
|
+
self.status(self.msg)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
self.error(e)
|
|
98
|
+
finally:
|
|
99
|
+
self.cleanup()
|
|
100
|
+
|
|
101
|
+
# ---------------------- HTTP / Config helpers ----------------------
|
|
102
|
+
|
|
103
|
+
def _timeout(self) -> int:
|
|
104
|
+
try:
|
|
105
|
+
return int(self.plugin.get_option_value("http_timeout") or 30)
|
|
106
|
+
except Exception:
|
|
107
|
+
return 30
|
|
108
|
+
|
|
109
|
+
def _headers(self) -> Dict[str, str]:
|
|
110
|
+
ua = (self.plugin.get_option_value("user_agent") or "").strip()
|
|
111
|
+
if not ua:
|
|
112
|
+
ua = "pygpt-net-osm-plugin/1.0 (+https://pygpt.net)"
|
|
113
|
+
return {
|
|
114
|
+
"User-Agent": ua,
|
|
115
|
+
"Accept": "*/*",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def _nominatim_base(self) -> str:
|
|
119
|
+
return (self.plugin.get_option_value("nominatim_base") or "https://nominatim.openstreetmap.org").rstrip("/")
|
|
120
|
+
|
|
121
|
+
def _osrm_base(self) -> str:
|
|
122
|
+
return (self.plugin.get_option_value("osrm_base") or "https://router.project-osrm.org").rstrip("/")
|
|
123
|
+
|
|
124
|
+
def _tile_base(self) -> str:
|
|
125
|
+
return (self.plugin.get_option_value("tile_base") or "https://tile.openstreetmap.org").rstrip("/")
|
|
126
|
+
|
|
127
|
+
def _nominatim_common_params(self) -> Dict[str, Any]:
|
|
128
|
+
p = {"format": "jsonv2"}
|
|
129
|
+
email = (self.plugin.get_option_value("contact_email") or "").strip()
|
|
130
|
+
if email:
|
|
131
|
+
p["email"] = email
|
|
132
|
+
lang = (self.plugin.get_option_value("accept_language") or "").strip()
|
|
133
|
+
if lang:
|
|
134
|
+
p["accept-language"] = lang
|
|
135
|
+
return p
|
|
136
|
+
|
|
137
|
+
# ---------------------- File / util helpers ----------------------
|
|
138
|
+
|
|
139
|
+
def _save_bytes(self, data: bytes, out_path: str) -> str:
|
|
140
|
+
local = self.prepare_path(out_path)
|
|
141
|
+
os.makedirs(os.path.dirname(local), exist_ok=True)
|
|
142
|
+
with open(local, "wb") as fh:
|
|
143
|
+
fh.write(data)
|
|
144
|
+
return local
|
|
145
|
+
|
|
146
|
+
def prepare_path(self, path: str) -> str:
|
|
147
|
+
if path in [".", "./"]:
|
|
148
|
+
return self.plugin.window.core.config.get_user_dir("data")
|
|
149
|
+
if self.is_absolute_path(path):
|
|
150
|
+
return path
|
|
151
|
+
return os.path.join(self.plugin.window.core.config.get_user_dir("data"), path)
|
|
152
|
+
|
|
153
|
+
def is_absolute_path(self, path: str) -> bool:
|
|
154
|
+
return os.path.isabs(path)
|
|
155
|
+
|
|
156
|
+
def _slug(self, s: str, maxlen: int = 80) -> str:
|
|
157
|
+
s = re.sub(r"\s+", "_", (s or "").strip())
|
|
158
|
+
s = re.sub(r"[^a-zA-Z0-9_\-\.]", "", s)
|
|
159
|
+
return (s[:maxlen] or "map").strip("._-") or "map"
|
|
160
|
+
|
|
161
|
+
def _now(self) -> int:
|
|
162
|
+
return int(time.time())
|
|
163
|
+
|
|
164
|
+
def _add_image(self, path: str):
|
|
165
|
+
try:
|
|
166
|
+
if self.ctx is None:
|
|
167
|
+
return
|
|
168
|
+
if path:
|
|
169
|
+
path = self.plugin.window.core.filesystem.to_workdir(path)
|
|
170
|
+
if not hasattr(self.ctx, "images_before") or self.ctx.images_before is None:
|
|
171
|
+
self.ctx.images_before = []
|
|
172
|
+
if path and path not in self.ctx.images_before:
|
|
173
|
+
self.ctx.images_before.append(path)
|
|
174
|
+
except Exception:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
# ---------------------- Internal helpers ----------------------
|
|
178
|
+
|
|
179
|
+
def _call_cmd(self, cmd: str, params: dict) -> dict:
|
|
180
|
+
fn = getattr(self, f"cmd_{cmd}", None)
|
|
181
|
+
if not callable(fn):
|
|
182
|
+
raise RuntimeError(f"Unknown command: {cmd}")
|
|
183
|
+
return fn({"cmd": cmd, "params": params})
|
|
184
|
+
|
|
185
|
+
def _get_payload(self, resp: Any) -> Any:
|
|
186
|
+
if isinstance(resp, dict):
|
|
187
|
+
if resp.get("data") is not None:
|
|
188
|
+
return resp["data"]
|
|
189
|
+
if resp.get("result") is not None:
|
|
190
|
+
return resp["result"]
|
|
191
|
+
return resp
|
|
192
|
+
|
|
193
|
+
# ---------------------- Geo helpers ----------------------
|
|
194
|
+
|
|
195
|
+
def _is_number(self, x: Any) -> bool:
|
|
196
|
+
try:
|
|
197
|
+
float(x)
|
|
198
|
+
return True
|
|
199
|
+
except Exception:
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
def _parse_point(self, v: Any) -> Optional[LatLon]:
|
|
203
|
+
if v is None:
|
|
204
|
+
return None
|
|
205
|
+
if isinstance(v, (list, tuple)) and len(v) >= 2 and self._is_number(v[0]) and self._is_number(v[1]):
|
|
206
|
+
return float(v[0]), float(v[1])
|
|
207
|
+
if isinstance(v, dict) and "lat" in v and "lon" in v and self._is_number(v["lat"]) and self._is_number(v["lon"]):
|
|
208
|
+
return float(v["lat"]), float(v["lon"])
|
|
209
|
+
if isinstance(v, str):
|
|
210
|
+
parts = [p.strip() for p in v.split(",")]
|
|
211
|
+
if len(parts) >= 2 and self._is_number(parts[0]) and self._is_number(parts[1]):
|
|
212
|
+
return float(parts[0]), float(parts[1])
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
def _validate_latlon(self, lat: float, lon: float) -> Tuple[float, float]:
|
|
216
|
+
if not (math.isfinite(lat) and math.isfinite(lon)):
|
|
217
|
+
raise ValueError("Non-finite coordinate")
|
|
218
|
+
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
|
|
219
|
+
raise ValueError("Coordinate out of range")
|
|
220
|
+
return float(lat), float(lon)
|
|
221
|
+
|
|
222
|
+
def _resolve_point(self, v: Any) -> LatLon:
|
|
223
|
+
pt = self._parse_point(v)
|
|
224
|
+
if pt:
|
|
225
|
+
return self._validate_latlon(pt[0], pt[1])
|
|
226
|
+
q = str(v).strip()
|
|
227
|
+
res = self._nominatim_search({"q": q, "limit": 1})
|
|
228
|
+
if res:
|
|
229
|
+
lat = float(res[0]["lat"])
|
|
230
|
+
lon = float(res[0]["lon"])
|
|
231
|
+
return self._validate_latlon(lat, lon)
|
|
232
|
+
raise RuntimeError(f"Unable to resolve location: {v}")
|
|
233
|
+
|
|
234
|
+
def _bbox_from_radius(self, lat: float, lon: float, radius_m: float) -> Tuple[float, float, float, float]:
|
|
235
|
+
dlat = radius_m / 111320.0
|
|
236
|
+
dlon = radius_m / (111320.0 * max(math.cos(math.radians(lat)), 1e-6))
|
|
237
|
+
return (lon - dlon, lat - dlat, lon + dlon, lat + dlat)
|
|
238
|
+
|
|
239
|
+
def _bbox_from_coords(self, coords: List[LatLon]) -> Tuple[float, float, float, float]:
|
|
240
|
+
lats = [c[0] for c in coords]
|
|
241
|
+
lons = [c[1] for c in coords]
|
|
242
|
+
return (min(lons), min(lats), max(lons), max(lats))
|
|
243
|
+
|
|
244
|
+
# ---------------------- OSM site URL helpers ----------------------
|
|
245
|
+
|
|
246
|
+
def _clamp(self, x: float, a: float, b: float) -> float:
|
|
247
|
+
return max(a, min(b, x))
|
|
248
|
+
|
|
249
|
+
def _lat_to_yn(self, lat: float) -> float:
|
|
250
|
+
lat = self._clamp(lat, -85.05112878, 85.05112878)
|
|
251
|
+
rad = math.radians(lat)
|
|
252
|
+
y = math.log(math.tan(math.pi / 4.0 + rad / 2.0))
|
|
253
|
+
return (1.0 - y / math.pi) / 2.0
|
|
254
|
+
|
|
255
|
+
def _estimate_zoom_for_bbox(self, minlon: float, minlat: float, maxlon: float, maxlat: float,
|
|
256
|
+
width: int, height: int) -> int:
|
|
257
|
+
dlon = max(1e-9, maxlon - minlon)
|
|
258
|
+
z_lon = math.log2((width * 360.0) / (256.0 * dlon))
|
|
259
|
+
y1 = self._lat_to_yn(minlat)
|
|
260
|
+
y2 = self._lat_to_yn(maxlat)
|
|
261
|
+
dy = max(1e-12, abs(y2 - y1))
|
|
262
|
+
z_lat = math.log2(height / (256.0 * dy))
|
|
263
|
+
z = int(max(0, min(20, math.floor(min(z_lon, z_lat)) - 1)))
|
|
264
|
+
return z
|
|
265
|
+
|
|
266
|
+
def _osm_site_url(self, lat: float, lon: float,
|
|
267
|
+
zoom: int | None = None,
|
|
268
|
+
marker: bool = True,
|
|
269
|
+
layers: str | None = None) -> str:
|
|
270
|
+
z = zoom if zoom is not None else int(self.plugin.get_option_value("map_zoom") or 14)
|
|
271
|
+
z = max(0, min(20, int(z)))
|
|
272
|
+
base = "https://www.openstreetmap.org/"
|
|
273
|
+
qparts = []
|
|
274
|
+
if marker:
|
|
275
|
+
qparts.append(f"mlat={lat:.6f}")
|
|
276
|
+
qparts.append(f"mlon={lon:.6f}")
|
|
277
|
+
if layers:
|
|
278
|
+
qparts.append(f"layers={layers}")
|
|
279
|
+
url = base
|
|
280
|
+
if qparts:
|
|
281
|
+
url += "?" + "&".join(qparts)
|
|
282
|
+
url += f"#map={z}/{lat:.6f}/{lon:.6f}"
|
|
283
|
+
return url
|
|
284
|
+
|
|
285
|
+
def _directions_url(self, start: LatLon, end: LatLon, mode: str | None = "car") -> str:
|
|
286
|
+
mode = (mode or "car").lower()
|
|
287
|
+
if mode in ("driving", "car"):
|
|
288
|
+
engine = "fossgis_osrm_car"
|
|
289
|
+
elif mode in ("cycling", "bike", "bicycle"):
|
|
290
|
+
engine = "fossgis_osrm_bike"
|
|
291
|
+
else:
|
|
292
|
+
engine = "fossgis_osrm_foot"
|
|
293
|
+
return f"https://www.openstreetmap.org/directions?engine={engine}&route={start[0]:.6f},{start[1]:.6f};{end[0]:.6f},{end[1]:.6f}"
|
|
294
|
+
|
|
295
|
+
def _directions_url_coords(self, coords: List[LatLon], mode: str | None = "car") -> str:
|
|
296
|
+
mode = (mode or "car").lower()
|
|
297
|
+
if mode in ("driving", "car"):
|
|
298
|
+
engine = "fossgis_osrm_car"
|
|
299
|
+
elif mode in ("cycling", "bike", "bicycle"):
|
|
300
|
+
engine = "fossgis_osrm_bike"
|
|
301
|
+
else:
|
|
302
|
+
engine = "fossgis_osrm_foot"
|
|
303
|
+
# route expects 'lat,lon;lat,lon;...' (note: OSM site, not OSRM API order)
|
|
304
|
+
parts = [f"{c[0]:.6f},{c[1]:.6f}" for c in coords]
|
|
305
|
+
return f"https://www.openstreetmap.org/directions?engine={engine}&route=" + ";".join(parts)
|
|
306
|
+
|
|
307
|
+
# ---------------------- Nominatim wrappers ----------------------
|
|
308
|
+
|
|
309
|
+
def _nominatim_search(self, params: Dict[str, Any]) -> List[dict]:
|
|
310
|
+
base = self._nominatim_base()
|
|
311
|
+
p = self._nominatim_common_params()
|
|
312
|
+
p.update(params or {})
|
|
313
|
+
url = f"{base}/search"
|
|
314
|
+
r = requests.get(url, headers=self._headers(), params=p, timeout=self._timeout())
|
|
315
|
+
if not (200 <= r.status_code < 300):
|
|
316
|
+
raise RuntimeError(f"Nominatim search HTTP {r.status_code}: {r.text[:200]}")
|
|
317
|
+
try:
|
|
318
|
+
data = r.json() or []
|
|
319
|
+
except Exception:
|
|
320
|
+
data = []
|
|
321
|
+
return data if isinstance(data, list) else []
|
|
322
|
+
|
|
323
|
+
def _nominatim_reverse(self, lat: float, lon: float, params: Dict[str, Any]) -> dict:
|
|
324
|
+
base = self._nominatim_base()
|
|
325
|
+
p = self._nominatim_common_params()
|
|
326
|
+
p.update({"lat": lat, "lon": lon})
|
|
327
|
+
p.update(params or {})
|
|
328
|
+
url = f"{base}/reverse"
|
|
329
|
+
r = requests.get(url, headers=self._headers(), params=p, timeout=self._timeout())
|
|
330
|
+
if not (200 <= r.status_code < 300):
|
|
331
|
+
raise RuntimeError(f"Nominatim reverse HTTP {r.status_code}: {r.text[:200]}")
|
|
332
|
+
try:
|
|
333
|
+
data = r.json() or {}
|
|
334
|
+
except Exception:
|
|
335
|
+
data = {}
|
|
336
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
337
|
+
|
|
338
|
+
# ---------------------- OSRM wrapper ----------------------
|
|
339
|
+
|
|
340
|
+
def _osrm_route(self, coords: List[LatLon], profile: str = "driving", opts: Optional[Dict[str, Any]] = None) -> dict:
|
|
341
|
+
if not coords or len(coords) < 2:
|
|
342
|
+
raise RuntimeError("At least 2 coordinates required for routing")
|
|
343
|
+
|
|
344
|
+
norm: List[str] = []
|
|
345
|
+
for (lat, lon) in coords:
|
|
346
|
+
lat, lon = self._validate_latlon(lat, lon)
|
|
347
|
+
norm.append(f"{lon:.6f},{lat:.6f}")
|
|
348
|
+
coord_str = ";".join(norm)
|
|
349
|
+
|
|
350
|
+
base = self._osrm_base()
|
|
351
|
+
profile = (profile or "driving").lower()
|
|
352
|
+
url = f"{base}/route/v1/{profile}/{coord_str}"
|
|
353
|
+
|
|
354
|
+
steps_bool = bool((opts or {}).get("steps", False))
|
|
355
|
+
alternatives_raw = (opts or {}).get("alternatives", 0)
|
|
356
|
+
try:
|
|
357
|
+
alternatives_bool = bool(int(alternatives_raw))
|
|
358
|
+
except Exception:
|
|
359
|
+
alternatives_bool = bool(alternatives_raw)
|
|
360
|
+
|
|
361
|
+
params = {
|
|
362
|
+
"overview": (opts or {}).get("overview", "false"),
|
|
363
|
+
"geometries": (opts or {}).get("geometries", "polyline6"),
|
|
364
|
+
"steps": "true" if steps_bool else "false",
|
|
365
|
+
"alternatives": "true" if alternatives_bool else "false",
|
|
366
|
+
"annotations": "false",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
r = requests.get(url, headers=self._headers(), params=params, timeout=self._timeout())
|
|
370
|
+
if not (200 <= r.status_code < 300):
|
|
371
|
+
raise RuntimeError(f"OSRM route HTTP {r.status_code}: {r.text[:200]}")
|
|
372
|
+
|
|
373
|
+
data = r.json()
|
|
374
|
+
if (data or {}).get("code") != "Ok":
|
|
375
|
+
raise RuntimeError(f"OSRM error: {(data or {}).get('message') or data}")
|
|
376
|
+
data["_request_url"] = r.request.url
|
|
377
|
+
return data
|
|
378
|
+
|
|
379
|
+
# ---------------------- Commands ----------------------
|
|
380
|
+
|
|
381
|
+
def cmd_osm_geocode(self, item: dict) -> dict:
|
|
382
|
+
p = item.get("params", {})
|
|
383
|
+
q = p.get("query") or p.get("q")
|
|
384
|
+
if not q:
|
|
385
|
+
return self.make_response(item, "Param 'query' required")
|
|
386
|
+
|
|
387
|
+
params: Dict[str, Any] = {"q": q}
|
|
388
|
+
if p.get("limit") is not None:
|
|
389
|
+
params["limit"] = int(p["limit"])
|
|
390
|
+
if p.get("countrycodes"):
|
|
391
|
+
params["countrycodes"] = p["countrycodes"]
|
|
392
|
+
if p.get("viewbox"):
|
|
393
|
+
vb = p["viewbox"]
|
|
394
|
+
if isinstance(vb, (list, tuple)) and len(vb) >= 4:
|
|
395
|
+
params["viewbox"] = ",".join(str(x) for x in vb[:4])
|
|
396
|
+
elif isinstance(vb, str):
|
|
397
|
+
params["viewbox"] = vb
|
|
398
|
+
if p.get("bounded") is not None:
|
|
399
|
+
params["bounded"] = 1 if p.get("bounded") else 0
|
|
400
|
+
if p.get("addressdetails") is not None:
|
|
401
|
+
params["addressdetails"] = 1 if p.get("addressdetails") else 0
|
|
402
|
+
if p.get("polygon_geojson"):
|
|
403
|
+
params["polygon_geojson"] = 1
|
|
404
|
+
|
|
405
|
+
if p.get("near") or (p.get("near_lat") is not None and p.get("near_lon") is not None):
|
|
406
|
+
try:
|
|
407
|
+
near = p.get("near") or {"lat": p.get("near_lat"), "lon": p.get("near_lon")}
|
|
408
|
+
latn, lonn = self._resolve_point(near)
|
|
409
|
+
radius = float(p.get("radius_m") or 1000.0)
|
|
410
|
+
minlon, minlat, maxlon, maxlat = self._bbox_from_radius(latn, lonn, radius)
|
|
411
|
+
params["viewbox"] = f"{minlon},{minlat},{maxlon},{maxlat}"
|
|
412
|
+
if p.get("bounded") is not None:
|
|
413
|
+
params["bounded"] = 1 if p.get("bounded") else 0
|
|
414
|
+
except Exception:
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
results = self._nominatim_search(params)
|
|
419
|
+
|
|
420
|
+
norm = []
|
|
421
|
+
zoom_for_url = int(p.get("zoom") or self.plugin.get_option_value("map_zoom") or 14)
|
|
422
|
+
layers = p.get("layers")
|
|
423
|
+
for r in results:
|
|
424
|
+
latf = float(r.get("lat")) if r.get("lat") else None
|
|
425
|
+
lonf = float(r.get("lon")) if r.get("lon") else None
|
|
426
|
+
item_res = {
|
|
427
|
+
"display_name": r.get("display_name"),
|
|
428
|
+
"lat": latf,
|
|
429
|
+
"lon": lonf,
|
|
430
|
+
"boundingbox": r.get("boundingbox"),
|
|
431
|
+
"type": r.get("type"),
|
|
432
|
+
"class": r.get("class"),
|
|
433
|
+
"importance": r.get("importance"),
|
|
434
|
+
"osm_id": r.get("osm_id"),
|
|
435
|
+
"osm_type": r.get("osm_type"),
|
|
436
|
+
"_raw": r,
|
|
437
|
+
}
|
|
438
|
+
if (latf is not None) and (lonf is not None):
|
|
439
|
+
item_res["map_url"] = self._osm_site_url(latf, lonf, zoom_for_url, marker=True, layers=layers)
|
|
440
|
+
norm.append(item_res)
|
|
441
|
+
return self.make_response(item, {"query": q, "count": len(norm), "results": norm})
|
|
442
|
+
except Exception as e:
|
|
443
|
+
return self.make_response(item, self.throw_error(e))
|
|
444
|
+
|
|
445
|
+
def cmd_osm_reverse(self, item: dict) -> dict:
|
|
446
|
+
p = item.get("params", {})
|
|
447
|
+
lat = p.get("lat")
|
|
448
|
+
lon = p.get("lon")
|
|
449
|
+
pt = self._parse_point([lat, lon]) if (lat is not None and lon is not None) else self._parse_point(p.get("point"))
|
|
450
|
+
if not pt:
|
|
451
|
+
return self.make_response(item, "Params 'lat' and 'lon' or 'point' required")
|
|
452
|
+
lat, lon = pt
|
|
453
|
+
params: Dict[str, Any] = {}
|
|
454
|
+
if p.get("zoom") is not None:
|
|
455
|
+
params["zoom"] = int(p["zoom"])
|
|
456
|
+
if p.get("addressdetails") is not None:
|
|
457
|
+
params["addressdetails"] = 1 if p.get("addressdetails") else 0
|
|
458
|
+
try:
|
|
459
|
+
data = self._nominatim_reverse(lat, lon, params)
|
|
460
|
+
res = {
|
|
461
|
+
"lat": lat, "lon": lon,
|
|
462
|
+
"display_name": data.get("display_name"),
|
|
463
|
+
"boundingbox": data.get("boundingbox"),
|
|
464
|
+
"address": data.get("address"),
|
|
465
|
+
"_raw": data,
|
|
466
|
+
}
|
|
467
|
+
zoom_for_url = int(p.get("zoom") or self.plugin.get_option_value("map_zoom") or 14)
|
|
468
|
+
layers = p.get("layers")
|
|
469
|
+
res["map_url"] = self._osm_site_url(lat, lon, zoom_for_url, marker=True, layers=layers)
|
|
470
|
+
return self.make_response(item, res)
|
|
471
|
+
except Exception as e:
|
|
472
|
+
return self.make_response(item, self.throw_error(e))
|
|
473
|
+
|
|
474
|
+
def cmd_osm_search(self, item: dict) -> dict:
|
|
475
|
+
return self._call_cmd("osm_geocode", item.get("params", {}))
|
|
476
|
+
|
|
477
|
+
def cmd_osm_route(self, item: dict) -> dict:
|
|
478
|
+
"""
|
|
479
|
+
Modes:
|
|
480
|
+
- mode="url": no OSRM call; returns only directions URL + waypoints.
|
|
481
|
+
- mode="summary" (default): OSRM with overview=false, steps=false; returns distance/duration + directions URL.
|
|
482
|
+
- mode="full": OSRM with overview=simplified, geometries=polyline6; optionally include geometry if include_geometry=True.
|
|
483
|
+
"""
|
|
484
|
+
p = item.get("params", {})
|
|
485
|
+
try:
|
|
486
|
+
start = self._resolve_point(p.get("start") or [p.get("start_lat"), p.get("start_lon")])
|
|
487
|
+
end = self._resolve_point(p.get("end") or [p.get("end_lat"), p.get("end_lon")])
|
|
488
|
+
except Exception:
|
|
489
|
+
return self.make_response(item, "Params 'start' and 'end' required (address or lat,lon)")
|
|
490
|
+
|
|
491
|
+
# Build coords list with optional waypoints
|
|
492
|
+
waypoints_in = p.get("waypoints") or []
|
|
493
|
+
coords: List[LatLon] = [start]
|
|
494
|
+
for w in waypoints_in:
|
|
495
|
+
try:
|
|
496
|
+
coords.append(self._resolve_point(w))
|
|
497
|
+
except Exception:
|
|
498
|
+
continue
|
|
499
|
+
coords.append(end)
|
|
500
|
+
|
|
501
|
+
profile = (p.get("profile") or "driving").lower()
|
|
502
|
+
mode = (p.get("mode") or "summary").lower()
|
|
503
|
+
|
|
504
|
+
directions_url = self._directions_url_coords(coords, mode=profile)
|
|
505
|
+
|
|
506
|
+
result: Dict[str, Any] = {
|
|
507
|
+
"profile": profile,
|
|
508
|
+
"waypoints": [{"lat": c[0], "lon": c[1]} for c in coords],
|
|
509
|
+
"map_url": directions_url,
|
|
510
|
+
"directions_url": directions_url,
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if mode == "url":
|
|
514
|
+
return self.make_response(item, result)
|
|
515
|
+
|
|
516
|
+
include_geometry = bool(p.get("include_geometry") or (mode == "full"))
|
|
517
|
+
include_steps = bool(p.get("include_steps") and mode == "full")
|
|
518
|
+
alternatives = p.get("alternatives") or 0
|
|
519
|
+
|
|
520
|
+
opts = {
|
|
521
|
+
"overview": "simplified" if include_geometry else "false",
|
|
522
|
+
"geometries": "polyline6",
|
|
523
|
+
"steps": include_steps,
|
|
524
|
+
"alternatives": alternatives,
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
data = self._osrm_route(coords, profile, opts)
|
|
529
|
+
route = (data.get("routes") or [{}])[0]
|
|
530
|
+
result["distance_m"] = route.get("distance")
|
|
531
|
+
result["duration_s"] = route.get("duration")
|
|
532
|
+
|
|
533
|
+
if include_geometry and "geometry" in route:
|
|
534
|
+
max_chars = int(p.get("max_polyline_chars") or 5000)
|
|
535
|
+
geom = route.get("geometry")
|
|
536
|
+
if isinstance(geom, str) and len(geom) > max_chars:
|
|
537
|
+
geom = geom[:max_chars] + "...(truncated)"
|
|
538
|
+
result["geometry_polyline6"] = geom
|
|
539
|
+
|
|
540
|
+
if p.get("save_map") or p.get("want_url"):
|
|
541
|
+
preview_bbox = None
|
|
542
|
+
if isinstance(route.get("geometry"), dict) and route["geometry"].get("type") == "LineString":
|
|
543
|
+
coords_geo: List[LatLon] = [(float(lat), float(lon)) for lon, lat in route["geometry"].get("coordinates", [])]
|
|
544
|
+
if coords_geo:
|
|
545
|
+
minlon, minlat, maxlon, maxlat = self._bbox_from_coords(coords_geo)
|
|
546
|
+
preview_bbox = [minlon, minlat, maxlon, maxlat]
|
|
547
|
+
|
|
548
|
+
sm = self._call_cmd("osm_staticmap", {
|
|
549
|
+
"bbox": preview_bbox,
|
|
550
|
+
"center": {"lat": start[0], "lon": start[1]} if not preview_bbox else None,
|
|
551
|
+
"zoom": p.get("zoom"),
|
|
552
|
+
"markers": [{"lat": start[0], "lon": start[1]}],
|
|
553
|
+
"layers": p.get("layers"),
|
|
554
|
+
"width": int(p.get("width") or self._map_width_default()),
|
|
555
|
+
"height": int(p.get("height") or self._map_height_default()),
|
|
556
|
+
})
|
|
557
|
+
smd = self._get_payload(sm)
|
|
558
|
+
if isinstance(smd, dict) and smd.get("ok") and smd.get("url"):
|
|
559
|
+
result["preview_url"] = smd["url"]
|
|
560
|
+
|
|
561
|
+
if p.get("debug_url"):
|
|
562
|
+
result["osrm_request_url"] = data.get("_request_url")
|
|
563
|
+
|
|
564
|
+
return self.make_response(item, result)
|
|
565
|
+
except Exception as e:
|
|
566
|
+
return self.make_response(item, self.throw_error(e))
|
|
567
|
+
|
|
568
|
+
def _map_width_default(self) -> int:
|
|
569
|
+
try:
|
|
570
|
+
return int(self.plugin.get_option_value("map_width") or 800)
|
|
571
|
+
except Exception:
|
|
572
|
+
return 800
|
|
573
|
+
|
|
574
|
+
def _map_height_default(self) -> int:
|
|
575
|
+
try:
|
|
576
|
+
return int(self.plugin.get_option_value("map_height") or 600)
|
|
577
|
+
except Exception:
|
|
578
|
+
return 600
|
|
579
|
+
|
|
580
|
+
def cmd_osm_staticmap(self, item: dict) -> dict:
|
|
581
|
+
"""
|
|
582
|
+
Build an openstreetmap.org URL instead of downloading a static image.
|
|
583
|
+
Supports center/zoom or bbox. Uses the first marker (if any) to set ?mlat/mlon.
|
|
584
|
+
"""
|
|
585
|
+
p = item.get("params", {})
|
|
586
|
+
|
|
587
|
+
width = int(p.get("width") or self._map_width_default())
|
|
588
|
+
height = int(p.get("height") or self._map_height_default())
|
|
589
|
+
|
|
590
|
+
center_pt = None
|
|
591
|
+
zoom = None
|
|
592
|
+
|
|
593
|
+
if p.get("bbox"):
|
|
594
|
+
vb = p["bbox"]
|
|
595
|
+
try:
|
|
596
|
+
if isinstance(vb, (list, tuple)) and len(vb) >= 4:
|
|
597
|
+
minlon, minlat, maxlon, maxlat = map(float, vb[:4])
|
|
598
|
+
elif isinstance(vb, str):
|
|
599
|
+
parts = [float(x) for x in vb.split(",")]
|
|
600
|
+
minlon, minlat, maxlon, maxlat = parts[:4]
|
|
601
|
+
else:
|
|
602
|
+
return self.make_response(item, "Invalid 'bbox' format")
|
|
603
|
+
|
|
604
|
+
clat = (minlat + maxlat) / 2.0
|
|
605
|
+
clon = (minlon + maxlon) / 2.0
|
|
606
|
+
center_pt = (clat, clon)
|
|
607
|
+
zoom = int(p.get("zoom")) if p.get("zoom") is not None else self._estimate_zoom_for_bbox(
|
|
608
|
+
minlon, minlat, maxlon, maxlat, width, height
|
|
609
|
+
)
|
|
610
|
+
except Exception:
|
|
611
|
+
return self.make_response(item, "Invalid 'bbox' format")
|
|
612
|
+
|
|
613
|
+
if center_pt is None:
|
|
614
|
+
if p.get("center"):
|
|
615
|
+
center_pt = self._resolve_point(p.get("center"))
|
|
616
|
+
elif (p.get("lat") is not None) and (p.get("lon") is not None):
|
|
617
|
+
center_pt = (float(p.get("lat")), float(p.get("lon")))
|
|
618
|
+
if zoom is None:
|
|
619
|
+
z = p.get("zoom")
|
|
620
|
+
zoom = int(z) if z is not None else int(self.plugin.get_option_value("map_zoom") or 14)
|
|
621
|
+
|
|
622
|
+
if center_pt is None:
|
|
623
|
+
mpt = None
|
|
624
|
+
markers = p.get("markers") or []
|
|
625
|
+
if isinstance(markers, (list, tuple)) and markers:
|
|
626
|
+
for m in markers:
|
|
627
|
+
mpt = self._parse_point(m)
|
|
628
|
+
if mpt:
|
|
629
|
+
break
|
|
630
|
+
elif isinstance(markers, str):
|
|
631
|
+
mpt = self._parse_point(markers)
|
|
632
|
+
if mpt:
|
|
633
|
+
center_pt = mpt
|
|
634
|
+
else:
|
|
635
|
+
return self.make_response(item, "Param 'center' or 'lat'/'lon' or 'bbox' required")
|
|
636
|
+
|
|
637
|
+
lat, lon = center_pt
|
|
638
|
+
|
|
639
|
+
mlat = None
|
|
640
|
+
mlon = None
|
|
641
|
+
markers = p.get("markers") or []
|
|
642
|
+
if isinstance(markers, (list, tuple)) and markers:
|
|
643
|
+
for m in markers:
|
|
644
|
+
mpt = self._parse_point(m)
|
|
645
|
+
if mpt:
|
|
646
|
+
mlat, mlon = mpt[0], mpt[1]
|
|
647
|
+
break
|
|
648
|
+
elif isinstance(markers, str):
|
|
649
|
+
mpt = self._parse_point(markers)
|
|
650
|
+
if mpt:
|
|
651
|
+
mlat, mlon = mpt[0], mpt[1]
|
|
652
|
+
elif p.get("marker"):
|
|
653
|
+
mlat, mlon = lat, lon
|
|
654
|
+
|
|
655
|
+
layers = p.get("layers")
|
|
656
|
+
qparts = []
|
|
657
|
+
if mlat is not None and mlon is not None:
|
|
658
|
+
qparts.append(f"mlat={mlat:.6f}")
|
|
659
|
+
qparts.append(f"mlon={mlon:.6f}")
|
|
660
|
+
if layers:
|
|
661
|
+
qparts.append(f"layers={layers}")
|
|
662
|
+
|
|
663
|
+
base = "https://www.openstreetmap.org/"
|
|
664
|
+
url = base
|
|
665
|
+
if qparts:
|
|
666
|
+
url += "?" + "&".join(qparts)
|
|
667
|
+
url += f"#map={int(zoom)}/{lat:.6f}/{lon:.6f}"
|
|
668
|
+
|
|
669
|
+
return self.make_response(item, {
|
|
670
|
+
"ok": True,
|
|
671
|
+
"url": url,
|
|
672
|
+
"center": {"lat": lat, "lon": lon},
|
|
673
|
+
"zoom": int(zoom),
|
|
674
|
+
"marker": {"lat": mlat, "lon": mlon} if (mlat is not None and mlon is not None) else None,
|
|
675
|
+
"service": "openstreetmap.org",
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
def cmd_osm_bbox_map(self, item: dict) -> dict:
|
|
679
|
+
p = item.get("params", {})
|
|
680
|
+
if not p.get("bbox"):
|
|
681
|
+
return self.make_response(item, "Param 'bbox' required (minlon,minlat,maxlon,maxlat)")
|
|
682
|
+
return self._call_cmd("osm_staticmap", p)
|
|
683
|
+
|
|
684
|
+
def cmd_osm_show_url(self, item: dict) -> dict:
|
|
685
|
+
p = item.get("params", {})
|
|
686
|
+
pt = self._parse_point(p.get("point") or [p.get("lat"), p.get("lon")])
|
|
687
|
+
if not pt:
|
|
688
|
+
return self.make_response(item, "Param 'point' or 'lat'+'lon' required")
|
|
689
|
+
lat, lon = pt
|
|
690
|
+
zoom = int(p.get("zoom") or self.plugin.get_option_value("map_zoom") or 15)
|
|
691
|
+
url = self._osm_site_url(lat, lon, zoom=zoom, marker=True, layers=p.get("layers"))
|
|
692
|
+
return self.make_response(item, {"url": url, "lat": lat, "lon": lon, "zoom": zoom})
|
|
693
|
+
|
|
694
|
+
def cmd_osm_route_url(self, item: dict) -> dict:
|
|
695
|
+
p = item.get("params", {})
|
|
696
|
+
try:
|
|
697
|
+
start = self._resolve_point(p.get("start") or [p.get("start_lat"), p.get("start_lon")])
|
|
698
|
+
end = self._resolve_point(p.get("end") or [p.get("end_lat"), p.get("end_lon")])
|
|
699
|
+
except Exception:
|
|
700
|
+
return self.make_response(item, "Params 'start' and 'end' required (address or lat,lon)")
|
|
701
|
+
url = self._directions_url(start, end, mode=(p.get("mode") or p.get("profile") or "car"))
|
|
702
|
+
return self.make_response(item, {"url": url})
|
|
703
|
+
|
|
704
|
+
def cmd_osm_tile(self, item: dict) -> dict:
|
|
705
|
+
p = item.get("params", {})
|
|
706
|
+
try:
|
|
707
|
+
z = int(p.get("z"))
|
|
708
|
+
x = int(p.get("x"))
|
|
709
|
+
y = int(p.get("y"))
|
|
710
|
+
except Exception:
|
|
711
|
+
return self.make_response(item, "Params 'z','x','y' required (tile indices)")
|
|
712
|
+
url = f"{self._tile_base()}/{z}/{x}/{y}.png"
|
|
713
|
+
r = requests.get(url, headers=self._headers(), timeout=self._timeout())
|
|
714
|
+
if not (200 <= r.status_code < 300):
|
|
715
|
+
return self.make_response(item, {"ok": False, "status": r.status_code, "error": r.text})
|
|
716
|
+
out = p.get("out") or os.path.join("openstreetmap", f"tile_{z}_{x}_{y}.png")
|
|
717
|
+
local = self._save_bytes(r.content, out)
|
|
718
|
+
self._add_image(local)
|
|
719
|
+
return self.make_response(item, {"ok": True, "file": local, "url": url})
|