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.
Files changed (61) hide show
  1. pygpt_net/CHANGELOG.txt +11 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +4 -0
  4. pygpt_net/controller/audio/audio.py +22 -1
  5. pygpt_net/controller/chat/chat.py +5 -1
  6. pygpt_net/controller/chat/remote_tools.py +116 -0
  7. pygpt_net/controller/lang/mapping.py +2 -1
  8. pygpt_net/controller/mode/mode.py +5 -2
  9. pygpt_net/controller/plugins/plugins.py +29 -3
  10. pygpt_net/controller/realtime/realtime.py +8 -3
  11. pygpt_net/controller/ui/mode.py +17 -5
  12. pygpt_net/core/agents/provider.py +16 -9
  13. pygpt_net/core/models/models.py +25 -1
  14. pygpt_net/core/render/web/renderer.py +3 -1
  15. pygpt_net/data/config/config.json +5 -4
  16. pygpt_net/data/config/models.json +3 -3
  17. pygpt_net/data/icons/web_off.svg +1 -0
  18. pygpt_net/data/icons/web_on.svg +1 -0
  19. pygpt_net/data/js/app.js +19 -0
  20. pygpt_net/data/locale/locale.de.ini +1 -0
  21. pygpt_net/data/locale/locale.en.ini +3 -2
  22. pygpt_net/data/locale/locale.es.ini +1 -0
  23. pygpt_net/data/locale/locale.fr.ini +1 -0
  24. pygpt_net/data/locale/locale.it.ini +1 -0
  25. pygpt_net/data/locale/locale.pl.ini +1 -4
  26. pygpt_net/data/locale/locale.uk.ini +1 -0
  27. pygpt_net/data/locale/locale.zh.ini +1 -0
  28. pygpt_net/data/locale/plugin.mcp.en.ini +4 -4
  29. pygpt_net/data/locale/plugin.osm.en.ini +35 -0
  30. pygpt_net/data/locale/plugin.wolfram.en.ini +24 -0
  31. pygpt_net/icons.qrc +2 -0
  32. pygpt_net/icons_rc.py +232 -147
  33. pygpt_net/js_rc.py +10490 -10432
  34. pygpt_net/plugin/base/worker.py +7 -1
  35. pygpt_net/plugin/osm/__init__.py +12 -0
  36. pygpt_net/plugin/osm/config.py +267 -0
  37. pygpt_net/plugin/osm/plugin.py +87 -0
  38. pygpt_net/plugin/osm/worker.py +719 -0
  39. pygpt_net/plugin/wolfram/__init__.py +12 -0
  40. pygpt_net/plugin/wolfram/config.py +214 -0
  41. pygpt_net/plugin/wolfram/plugin.py +115 -0
  42. pygpt_net/plugin/wolfram/worker.py +551 -0
  43. pygpt_net/provider/api/anthropic/tools.py +4 -2
  44. pygpt_net/provider/api/google/__init__.py +3 -2
  45. pygpt_net/provider/api/google/video.py +0 -0
  46. pygpt_net/provider/api/openai/agents/experts.py +1 -1
  47. pygpt_net/provider/api/openai/agents/remote_tools.py +14 -4
  48. pygpt_net/provider/api/openai/chat.py +7 -2
  49. pygpt_net/provider/api/openai/remote_tools.py +5 -2
  50. pygpt_net/provider/api/x_ai/remote.py +6 -1
  51. pygpt_net/provider/core/config/patch.py +8 -1
  52. pygpt_net/provider/llms/anthropic.py +29 -1
  53. pygpt_net/provider/llms/google.py +30 -1
  54. pygpt_net/provider/llms/open_router.py +3 -1
  55. pygpt_net/provider/llms/x_ai.py +21 -1
  56. pygpt_net/ui/layout/chat/output.py +7 -2
  57. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/METADATA +37 -2
  58. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/RECORD +60 -47
  59. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/LICENSE +0 -0
  60. {pygpt_net-2.6.52.dist-info → pygpt_net-2.6.54.dist-info}/WHEEL +0 -0
  61. {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})