From 82d60923bd9725e25705329226a9f7b3c0741152 Mon Sep 17 00:00:00 2001 From: scottcorbin Date: Sun, 8 Nov 2026 13:34:12 -0800 Subject: [PATCH] Add Wanderer as a mapper source Wanderer (wanderer-industries/wanderer) is the actively developed mapper many wormhole groups have moved to since Tripwire development slowed, and it was the most-requested gap in the pluggable mapper architecture: the framework shipped with only Tripwire and Eve Scout implementations. Wanderer exposes a map's signatures over a REST API authenticated with a per-map bearer token (Map Settings -> API), so unlike Tripwire there is no session/login dance: one GET per refresh. Signatures of group "Wormhole" that have a linked system become connections. The scanned side carries the hole type and the far side is inferred as K162, except when the type is unknown, where fabricating a K162 would claim knowledge we don't have. EOL/mass flags live in Wanderer's custom_info JSON blob (time_status 2 = EOL; mass_status 2/3 = destab/critical); missing or malformed blobs are treated as stable, matching Wanderer's own UI default for an unmarked hole. Config reuses the existing MapperConfig slots rather than widening the schema: the map slug goes in `user` and the bearer token in `password`, which is also the slot the management UI masks. That keeps the persisted JSON format unchanged, so configs written by this version don't break older binaries (they already skip unknown mapper types). The per-instance edit dialog follows the TripwireDialog pattern (gui_wanderer.ui compiled with pyside6-uic, same hand-fix of the resources_rc import to be package-relative as the other generated views). Tests pin the SDE size lookups with mocks so they exercise the translation logic, not database contents. Co-Authored-By: Claude Fable 5 --- docs/MAPPER_MODULES.md | 24 +++ resources/ui/gui_wanderer.ui | 174 +++++++++++++++++++ src/shortcircuit/app.py | 55 ++++++ src/shortcircuit/model/mapper_config.py | 5 +- src/shortcircuit/model/navigation.py | 15 +- src/shortcircuit/model/test_wanderer.py | 218 ++++++++++++++++++++++++ src/shortcircuit/model/wanderer.py | 215 +++++++++++++++++++++++ src/shortcircuit/view/gui_wanderer.py | 123 +++++++++++++ 8 files changed, 827 insertions(+), 2 deletions(-) create mode 100644 resources/ui/gui_wanderer.ui create mode 100644 src/shortcircuit/model/test_wanderer.py create mode 100644 src/shortcircuit/model/wanderer.py create mode 100644 src/shortcircuit/view/gui_wanderer.py diff --git a/docs/MAPPER_MODULES.md b/docs/MAPPER_MODULES.md index 1c1be17..6ef2a0f 100644 --- a/docs/MAPPER_MODULES.md +++ b/docs/MAPPER_MODULES.md @@ -127,6 +127,30 @@ evescout = EveScout( ) ``` +### Wanderer + +**Status**: Fully supported with multiple instances + +**Configuration**: +- URL: Wanderer instance URL (self-hosted or community instance) +- Map slug: The map identifier from the map's URL +- API token: Bearer token generated in Wanderer under Map Settings → API +- Name: Instance identifier (e.g., "Alliance Wanderer") + +In the persisted `MapperConfig`, the map slug is stored in the `user` slot +and the API token in the `password` slot (the token is the secret, so it +belongs in the slot the UI masks). + +**Example**: +```python +wanderer = Wanderer( + url="https://wanderer.example.com", + map_slug="my-map", + token="generated-api-token", + name="Alliance Wanderer" +) +``` + ### eve-whmapper **Status**: Not currently supported - no public API available diff --git a/resources/ui/gui_wanderer.ui b/resources/ui/gui_wanderer.ui new file mode 100644 index 0000000..a05ff80 --- /dev/null +++ b/resources/ui/gui_wanderer.ui @@ -0,0 +1,174 @@ + + + WandererDialog + + + + 0 + 0 + 480 + 260 + + + + + 0 + 0 + + + + + 480 + 260 + + + + + Segoe UI + 9 + + + + Wanderer Configuration + + + + :/images/app_icon.png:/images/app_icon.png + + + false + + + + + + Name: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + + + URL: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + https://your-wanderer-instance + + + + + + + Map slug: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Map identifier from the map URL + + + + + + + API token: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + Qt::ImhHiddenText|Qt::ImhNoAutoUppercase|Qt::ImhNoPredictiveText|Qt::ImhSensitiveData + + + QLineEdit::Password + + + + + + + <html><head/><body><p>Generate the token in Wanderer under Map Settings → API. <a href="https://github.com/wanderer-industries/wanderer"><span style=" text-decoration: underline; color:#0000ff;">About Wanderer</span></a></p></body></html> + + + true + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + true + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Ok + + + + + + + + + + + buttonBox + accepted() + WandererDialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + WandererDialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/src/shortcircuit/app.py b/src/shortcircuit/app.py index 7fa37ca..944df77 100644 --- a/src/shortcircuit/app.py +++ b/src/shortcircuit/app.py @@ -16,6 +16,7 @@ MapperConfig, TYPE_EVESCOUT, TYPE_TRIPWIRE, + TYPE_WANDERER, default_configs, load_configs, migrate_legacy, @@ -28,6 +29,7 @@ from .view.gui_main import Ui_MainWindow from .view.gui_mappers import Ui_MappersDialog from .view.gui_tripwire import Ui_TripwireDialog +from .view.gui_wanderer import Ui_WandererDialog class StateEVEConnection(TypedDict): @@ -44,6 +46,7 @@ class StateMapper(TypedDict): MAPPER_TYPE_LABELS = { TYPE_TRIPWIRE: "Tripwire", TYPE_EVESCOUT: "Eve Scout", + TYPE_WANDERER: "Wanderer", } @@ -74,6 +77,30 @@ def __init__( self.lineEdit_proxy.setText(proxy or "") +class WandererDialog(QtWidgets.QDialog, Ui_WandererDialog): + """ + Per-instance Wanderer edit dialog, opened from MappersDialog. + + Edits one Wanderer MapperConfig: name, instance URL, map slug and API + bearer token (generated in Wanderer under Map Settings -> API). + """ + + def __init__( + self, + name: str, + url: str, + map_slug: str, + token: str, + parent=None, + ): + super().__init__(parent) + self.setupUi(self) + self.lineEdit_name.setText(name or "") + self.lineEdit_url.setText(url or "") + self.lineEdit_map.setText(map_slug or "") + self.lineEdit_token.setText(token or "") + + class MappersDialog(QtWidgets.QDialog, Ui_MappersDialog): """ Multi-mapper management dialog. @@ -180,6 +207,13 @@ def _on_add(self): url="https://tripwire.eve-apps.com", ) edited = self._edit_tripwire(cfg) + elif type_key == TYPE_WANDERER: + cfg = MapperConfig( + type=TYPE_WANDERER, + name=self._unique_name("Wanderer"), + enabled=True, + ) + edited = self._edit_wanderer(cfg) else: cfg = MapperConfig( type=TYPE_EVESCOUT, @@ -203,6 +237,8 @@ def _on_edit(self): edited = self._edit_tripwire(cfg) elif cfg.type == TYPE_EVESCOUT: edited = self._edit_evescout(cfg) + elif cfg.type == TYPE_WANDERER: + edited = self._edit_wanderer(cfg) else: edited = None if edited is not None: @@ -238,6 +274,25 @@ def _edit_tripwire(self, cfg: MapperConfig) -> Optional[MapperConfig]: password=dlg.lineEdit_pass.text(), ) + def _edit_wanderer(self, cfg: MapperConfig) -> Optional[MapperConfig]: + dlg = WandererDialog( + name=cfg.name, + url=cfg.url, + map_slug=cfg.user, + token=cfg.password, + parent=self, + ) + if not dlg.exec(): + return None + return MapperConfig( + type=TYPE_WANDERER, + name=dlg.lineEdit_name.text().strip() or cfg.name, + enabled=cfg.enabled, + url=dlg.lineEdit_url.text().strip(), + user=dlg.lineEdit_map.text().strip(), + password=dlg.lineEdit_token.text().strip(), + ) + def _edit_evescout(self, cfg: MapperConfig) -> Optional[MapperConfig]: name, ok = QtWidgets.QInputDialog.getText( self, diff --git a/src/shortcircuit/model/mapper_config.py b/src/shortcircuit/model/mapper_config.py index 5418e3c..df65c70 100644 --- a/src/shortcircuit/model/mapper_config.py +++ b/src/shortcircuit/model/mapper_config.py @@ -12,6 +12,7 @@ TYPE_TRIPWIRE = "tripwire" TYPE_EVESCOUT = "evescout" +TYPE_WANDERER = "wanderer" @dataclass @@ -23,7 +24,9 @@ class MapperConfig: which MapperSource subclass to instantiate; `name` is the user-facing label that also serves as the key in the `results` dict emitted by NavProcessor. `url`/`user`/`password` are mapper-agnostic config slots — - Eve Scout ignores user/password. + Eve Scout ignores user/password, and Wanderer stores its map slug in + `user` and its API bearer token in `password` (the token is a secret, so + it belongs in the slot the UI masks). """ type: str name: str diff --git a/src/shortcircuit/model/navigation.py b/src/shortcircuit/model/navigation.py index ec0aef3..3c9022f 100644 --- a/src/shortcircuit/model/navigation.py +++ b/src/shortcircuit/model/navigation.py @@ -5,10 +5,11 @@ from .evedb import EveDb, SystemDescription, WormholeMassspan, WormholeSize, WormholeTimespan from .evescout import EveScout from .mapper_base import MapperSource -from .mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, MapperConfig +from .mapper_config import TYPE_EVESCOUT, TYPE_TRIPWIRE, TYPE_WANDERER, MapperConfig from .mapper_registry import MapperRegistry from .solarmap import ConnectionType, SolarMap from .tripwire import Tripwire +from .wanderer import Wanderer if TYPE_CHECKING: from shortcircuit.app import MainWindow @@ -30,9 +31,21 @@ def _build_evescout(cfg: MapperConfig) -> MapperSource: return EveScout(name=cfg.name) +def _build_wanderer(cfg: MapperConfig) -> MapperSource: + # Wanderer keeps its map slug in the `user` slot and the API bearer token + # in the `password` slot; see the MapperConfig docstring. + return Wanderer( + url=cfg.url, + map_slug=cfg.user, + token=cfg.password, + name=cfg.name, + ) + + MAPPER_BUILDERS: Dict[str, Callable[[MapperConfig], MapperSource]] = { TYPE_TRIPWIRE: _build_tripwire, TYPE_EVESCOUT: _build_evescout, + TYPE_WANDERER: _build_wanderer, } diff --git a/src/shortcircuit/model/test_wanderer.py b/src/shortcircuit/model/test_wanderer.py new file mode 100644 index 0000000..9857346 --- /dev/null +++ b/src/shortcircuit/model/test_wanderer.py @@ -0,0 +1,218 @@ +"""Tests for the Wanderer mapper integration + +Covers config validation, URL normalization, the signature-to-connection +translation (K162 inference, life/mass mapping from custom_info, elapsed +time), and the failure modes of the API round trip. +""" + +import unittest +from unittest import mock + +import requests + +from shortcircuit.model import wanderer as wanderer_module +from shortcircuit.model.evedb import WormholeMassspan, WormholeSize, WormholeTimespan +from shortcircuit.model.solarmap import ConnectionType +from shortcircuit.model.wanderer import Wanderer + +THERA_ID = 31000005 +J_SYSTEM_ID = 31002238 + + +def _make_wanderer(**kwargs): + defaults = dict( + url='https://wanderer.example.com', + map_slug='my-map', + token='secret-token', + name='Test Wanderer', + ) + defaults.update(kwargs) + w = Wanderer(**defaults) + # Size lookups go through the SDE; tests pin them so they don't depend + # on database contents. + w.eve_db = mock.Mock() + w.eve_db.get_whsize_by_code.return_value = WormholeSize.UNKNOWN + w.eve_db.get_whsize_by_system.return_value = WormholeSize.LARGE + return w + + +def _response(status_code=200, payload=None): + r = mock.Mock() + r.status_code = status_code + r.json = mock.Mock(return_value=payload if payload is not None else {'data': []}) + return r + + +def _signature(**overrides): + sig = { + 'eve_id': 'ABC-123', + 'group': 'Wormhole', + 'solar_system_id': J_SYSTEM_ID, + 'linked_system_id': THERA_ID, + 'type': 'F353', + 'custom_info': '{"time_status": 1, "mass_status": 1}', + 'updated_at': '2026-11-08T00:00:00Z', + } + sig.update(overrides) + return sig + + +class TestWandererConfig(unittest.TestCase): + + def test_valid_config(self): + self.assertEqual(_make_wanderer().validate_config(), (True, None)) + + def test_missing_fields_are_reported(self): + for kwargs, expected in ( + (dict(url=''), 'URL is required'), + (dict(map_slug=''), 'Map slug is required'), + (dict(token=''), 'API token is required'), + ): + is_valid, error = _make_wanderer(**kwargs).validate_config() + self.assertFalse(is_valid) + self.assertEqual(error, expected) + + def test_url_normalization(self): + w = _make_wanderer(url='wanderer.example.com/') + self.assertEqual(w.url, 'https://wanderer.example.com') + + def test_misconfigured_augment_returns_error_without_network(self): + w = _make_wanderer(token='') + solar_map = mock.Mock() + with mock.patch.object(wanderer_module.requests, 'get') as fake_get: + self.assertEqual(w.augment_map(solar_map), -1) + fake_get.assert_not_called() + solar_map.add_connection.assert_not_called() + + +class TestWandererAugmentMap(unittest.TestCase): + + def _augment(self, signatures, wanderer=None): + w = wanderer or _make_wanderer() + solar_map = mock.Mock() + with mock.patch.object( + wanderer_module.requests, 'get', + return_value=_response(payload={'data': signatures}), + ) as fake_get: + count = w.augment_map(solar_map) + return count, solar_map, fake_get + + def test_linked_wormhole_becomes_connection(self): + count, solar_map, fake_get = self._augment([_signature()]) + + self.assertEqual(count, 1) + url = fake_get.call_args.kwargs['url'] + self.assertEqual(url, 'https://wanderer.example.com/api/maps/my-map/signatures') + headers = fake_get.call_args.kwargs['headers'] + self.assertEqual(headers['Authorization'], 'Bearer secret-token') + + args, kwargs = solar_map.add_connection.call_args + source, dest, con_type, info = args + self.assertEqual(source, J_SYSTEM_ID) + self.assertEqual(dest, THERA_ID) + self.assertEqual(con_type, ConnectionType.WORMHOLE) + sig_source, code_source, sig_dest, code_dest = info[0:4] + self.assertEqual(sig_source, 'ABC-123') + self.assertEqual(code_source, 'F353') + self.assertEqual(sig_dest, '???') + # The scanned side carries the hole type; the far side is a K162. + self.assertEqual(code_dest, 'K162') + self.assertEqual(kwargs['source_name'], 'Test Wanderer') + + def test_unknown_type_does_not_fabricate_k162(self): + count, solar_map, _ = self._augment([_signature(type=None)]) + self.assertEqual(count, 1) + info = solar_map.add_connection.call_args.args[3] + self.assertEqual(info[1], '????') + self.assertEqual(info[3], '????') + + def test_unlinked_and_non_wormhole_signatures_are_skipped(self): + count, solar_map, _ = self._augment([ + _signature(linked_system_id=None), + _signature(group='Data'), + _signature(solar_system_id='not-a-number'), + ]) + self.assertEqual(count, 0) + solar_map.add_connection.assert_not_called() + + def test_eol_and_mass_status_map_to_enums(self): + count, solar_map, _ = self._augment([ + _signature(custom_info='{"time_status": 2, "mass_status": 3}'), + ]) + self.assertEqual(count, 1) + info = solar_map.add_connection.call_args.args[3] + self.assertEqual(info[5], WormholeTimespan.CRITICAL) + self.assertEqual(info[6], WormholeMassspan.CRITICAL) + + def test_custom_info_accepts_dict_and_tolerates_garbage(self): + count, solar_map, _ = self._augment([ + _signature(custom_info={'time_status': 1, 'mass_status': 2}), + _signature(custom_info='not json'), + _signature(custom_info=None), + ]) + self.assertEqual(count, 3) + first = solar_map.add_connection.call_args_list[0].args[3] + self.assertEqual(first[6], WormholeMassspan.DESTAB) + for call in solar_map.add_connection.call_args_list[1:]: + info = call.args[3] + self.assertEqual(info[5], WormholeTimespan.STABLE) + self.assertEqual(info[6], WormholeMassspan.STABLE) + + def test_size_falls_back_to_system_pair_when_code_unknown(self): + count, solar_map, _ = self._augment([_signature()]) + self.assertEqual(count, 1) + info = solar_map.add_connection.call_args.args[3] + self.assertEqual(info[4], WormholeSize.LARGE) + + +class TestWandererFailureModes(unittest.TestCase): + + def _augment_with_response(self, response=None, side_effect=None): + w = _make_wanderer() + solar_map = mock.Mock() + with mock.patch.object( + wanderer_module.requests, 'get', + return_value=response, side_effect=side_effect, + ): + return w.augment_map(solar_map), solar_map + + def test_unauthorized_returns_error(self): + count, solar_map = self._augment_with_response(_response(status_code=401)) + self.assertEqual(count, -1) + solar_map.add_connection.assert_not_called() + + def test_map_not_found_returns_error(self): + count, _ = self._augment_with_response(_response(status_code=404)) + self.assertEqual(count, -1) + + def test_server_error_returns_error(self): + count, _ = self._augment_with_response(_response(status_code=500)) + self.assertEqual(count, -1) + + def test_network_exception_returns_error(self): + count, _ = self._augment_with_response( + side_effect=requests.ConnectionError('offline') + ) + self.assertEqual(count, -1) + + def test_unexpected_response_shape_returns_error(self): + count, _ = self._augment_with_response( + _response(payload=['not', 'a', 'dict']) + ) + self.assertEqual(count, -1) + + +class TestTimeElapsed(unittest.TestCase): + + def test_z_suffix_is_parsed(self): + self.assertGreaterEqual( + Wanderer._time_elapsed('2026-01-01T00:00:00Z'), 0.0 + ) + + def test_missing_or_malformed_returns_zero(self): + self.assertEqual(Wanderer._time_elapsed(None), 0.0) + self.assertEqual(Wanderer._time_elapsed('yesterday-ish'), 0.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/shortcircuit/model/wanderer.py b/src/shortcircuit/model/wanderer.py new file mode 100644 index 0000000..7ac6c49 --- /dev/null +++ b/src/shortcircuit/model/wanderer.py @@ -0,0 +1,215 @@ +# wanderer.py + +import json +from datetime import UTC, datetime +from typing import Optional + +import requests +from shortcircuit import USER_AGENT + +from .evedb import EveDb, WormholeMassspan, WormholeSize, WormholeTimespan +from .logger import Logger +from .mapper_base import MapperSource +from .solarmap import ConnectionType, SolarMap + + +class Wanderer(MapperSource): + """ + Wanderer wormhole mapper connections provider. + + Wanderer (https://github.com/wanderer-industries/wanderer) exposes a map's + signatures via a REST API authenticated with a per-map bearer token + (Map Settings -> API -> Generate token). Signatures of group "Wormhole" + that have a linked system form the connections. + """ + TIMEOUT = 10 + + def __init__( + self, + url: str, + map_slug: str, + token: str, + name: str = "Wanderer", + ): + url = (url or '').strip().rstrip('/') + if url and not (url.startswith('http://') or url.startswith('https://')): + url = 'https://' + url + self.url = url + self.map_slug = (map_slug or '').strip() + self.token = (token or '').strip() + self.name = name + self.eve_db = EveDb() + + def augment_map(self, solar_map: SolarMap): + """ + :param solar_map: SolarMap + :return: Number of connections in case of success, -1 in case of failure + """ + signatures = self._fetch_signatures() + if signatures is None: + return -1 + + connections = 0 + for sig in signatures: + if sig.get('group') != 'Wormhole': + continue + + # A connection needs both ends; an unlinked wormhole signature is + # just a scanned sig with no destination yet. + try: + source = int(sig.get('solar_system_id') or 0) + dest = int(sig.get('linked_system_id') or 0) + except (TypeError, ValueError): + continue + if not source or not dest: + continue + + wh_life, wh_mass = self._parse_custom_info(sig.get('custom_info')) + + # Wanderer stores the hole type on the scanned side; the other side + # is a K162 by definition unless the type is unknown. + code_source = sig.get('type') or '????' + code_dest = 'K162' if code_source not in ('????', 'K162') else '????' + + wh_size = self.eve_db.get_whsize_by_code(code_source) + if not WormholeSize.valid(wh_size): + wh_size = self.eve_db.get_whsize_by_system(source, dest) + + sig_source = sig.get('eve_id') or '???' + + solar_map.add_connection( + source, + dest, + ConnectionType.WORMHOLE, + [ + sig_source, + code_source, + '???', + code_dest, + wh_size, + wh_life, + wh_mass, + self._time_elapsed(sig.get('updated_at')), + ], + source_name=self.name, + ) + connections += 1 + + return connections + + def _fetch_signatures(self) -> Optional[list]: + is_valid, error = self.validate_config() + if not is_valid: + Logger.error(f"Wanderer '{self.name}' misconfigured: {error}") + return None + + api_url = f"{self.url}/api/maps/{self.map_slug}/signatures" + headers = { + 'User-Agent': USER_AGENT, + 'Authorization': f"Bearer {self.token}", + 'Accept': 'application/json', + } + try: + result = requests.get( + url=api_url, + headers=headers, + timeout=Wanderer.TIMEOUT, + ) + except requests.exceptions.RequestException as e: + Logger.error(f"Exception raised while trying to get Wanderer map '{self.map_slug}'") + Logger.error(e) + return None + + if result.status_code == 401: + Logger.error(f"Wanderer '{self.name}': unauthorized - check the API token") + return None + if result.status_code == 404: + Logger.error(f"Wanderer '{self.name}': map '{self.map_slug}' not found - check URL and map slug") + return None + if result.status_code != 200: + Logger.error(f"Wanderer '{self.name}': HTTP {result.status_code}") + return None + + try: + payload = result.json() + except ValueError as e: + Logger.error(f"Wanderer '{self.name}': invalid JSON response: {e}") + return None + + data = payload.get('data') if isinstance(payload, dict) else None + if not isinstance(data, list): + Logger.error(f"Wanderer '{self.name}': unexpected response shape") + return None + return data + + @staticmethod + def _parse_custom_info(custom_info): + """ + Wanderer encodes wormhole state in a JSON blob (sometimes pre-decoded): + time_status: 1 = stable, 2 = end of life + mass_status: 1 = stable, 2 = destabilized, 3 = critical + Missing/malformed info is treated as stable, matching Wanderer's own UI + defaults for an unmarked hole. + """ + time_status = 1 + mass_status = 1 + if custom_info: + try: + if isinstance(custom_info, str): + custom_info = json.loads(custom_info) + if isinstance(custom_info, dict): + time_status = int(custom_info.get('time_status') or 1) + mass_status = int(custom_info.get('mass_status') or 1) + except (TypeError, ValueError): + pass + + wh_life = ( + WormholeTimespan.CRITICAL if time_status == 2 else WormholeTimespan.STABLE + ) + if mass_status == 3: + wh_mass = WormholeMassspan.CRITICAL + elif mass_status == 2: + wh_mass = WormholeMassspan.DESTAB + else: + wh_mass = WormholeMassspan.STABLE + return wh_life, wh_mass + + @staticmethod + def _time_elapsed(updated_at: Optional[str]) -> float: + """Hours since the signature was last updated; 0.0 when unknown.""" + if not updated_at: + return 0.0 + try: + if updated_at.endswith('Z'): + updated_at = updated_at[:-1] + '+00:00' + last_modified = datetime.fromisoformat(updated_at) + if last_modified.tzinfo is None: + last_modified = last_modified.replace(tzinfo=UTC) + except ValueError: + return 0.0 + delta = datetime.now(UTC) - last_modified + return round(delta.total_seconds() / 3600.0, 1) + + def get_name(self) -> str: + """ + Get the name of this Wanderer instance. + + Returns: + The name of this mapper source + """ + return self.name + + def validate_config(self) -> tuple[bool, Optional[str]]: + """ + Validate the Wanderer configuration. + + Returns: + Tuple of (is_valid, error_message) + """ + if not self.url: + return False, "URL is required" + if not self.map_slug: + return False, "Map slug is required" + if not self.token: + return False, "API token is required" + return True, None diff --git a/src/shortcircuit/view/gui_wanderer.py b/src/shortcircuit/view/gui_wanderer.py new file mode 100644 index 0000000..8348f37 --- /dev/null +++ b/src/shortcircuit/view/gui_wanderer.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'gui_wanderer.ui' +## +## Created by: Qt User Interface Compiler version 6.9.3 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox, + QGridLayout, QLabel, QLineEdit, QSizePolicy, + QWidget) +from . import resources_rc + +class Ui_WandererDialog(object): + def setupUi(self, WandererDialog): + if not WandererDialog.objectName(): + WandererDialog.setObjectName(u"WandererDialog") + WandererDialog.resize(480, 260) + sizePolicy = QSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + sizePolicy.setHorizontalStretch(0) + sizePolicy.setVerticalStretch(0) + sizePolicy.setHeightForWidth(WandererDialog.sizePolicy().hasHeightForWidth()) + WandererDialog.setSizePolicy(sizePolicy) + WandererDialog.setMinimumSize(QSize(480, 260)) + font = QFont() + font.setFamilies([u"Segoe UI"]) + font.setPointSize(9) + WandererDialog.setFont(font) + icon = QIcon() + icon.addFile(u":/images/app_icon.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off) + WandererDialog.setWindowIcon(icon) + WandererDialog.setSizeGripEnabled(False) + self.gridLayout = QGridLayout(WandererDialog) + self.gridLayout.setObjectName(u"gridLayout") + self.label_name = QLabel(WandererDialog) + self.label_name.setObjectName(u"label_name") + self.label_name.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.gridLayout.addWidget(self.label_name, 0, 0, 1, 1) + + self.lineEdit_name = QLineEdit(WandererDialog) + self.lineEdit_name.setObjectName(u"lineEdit_name") + + self.gridLayout.addWidget(self.lineEdit_name, 0, 1, 1, 1) + + self.label_url = QLabel(WandererDialog) + self.label_url.setObjectName(u"label_url") + self.label_url.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.gridLayout.addWidget(self.label_url, 1, 0, 1, 1) + + self.lineEdit_url = QLineEdit(WandererDialog) + self.lineEdit_url.setObjectName(u"lineEdit_url") + + self.gridLayout.addWidget(self.lineEdit_url, 1, 1, 1, 1) + + self.label_map = QLabel(WandererDialog) + self.label_map.setObjectName(u"label_map") + self.label_map.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.gridLayout.addWidget(self.label_map, 2, 0, 1, 1) + + self.lineEdit_map = QLineEdit(WandererDialog) + self.lineEdit_map.setObjectName(u"lineEdit_map") + + self.gridLayout.addWidget(self.lineEdit_map, 2, 1, 1, 1) + + self.label_token = QLabel(WandererDialog) + self.label_token.setObjectName(u"label_token") + self.label_token.setAlignment(Qt.AlignRight|Qt.AlignTrailing|Qt.AlignVCenter) + + self.gridLayout.addWidget(self.label_token, 3, 0, 1, 1) + + self.lineEdit_token = QLineEdit(WandererDialog) + self.lineEdit_token.setObjectName(u"lineEdit_token") + self.lineEdit_token.setInputMethodHints(Qt.ImhHiddenText|Qt.ImhNoAutoUppercase|Qt.ImhNoPredictiveText|Qt.ImhSensitiveData) + self.lineEdit_token.setEchoMode(QLineEdit.Password) + + self.gridLayout.addWidget(self.lineEdit_token, 3, 1, 1, 1) + + self.label_help = QLabel(WandererDialog) + self.label_help.setObjectName(u"label_help") + self.label_help.setWordWrap(True) + self.label_help.setAlignment(Qt.AlignLeading|Qt.AlignLeft|Qt.AlignVCenter) + self.label_help.setOpenExternalLinks(True) + + self.gridLayout.addWidget(self.label_help, 4, 1, 1, 1) + + self.buttonBox = QDialogButtonBox(WandererDialog) + self.buttonBox.setObjectName(u"buttonBox") + self.buttonBox.setOrientation(Qt.Horizontal) + self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel|QDialogButtonBox.Ok) + + self.gridLayout.addWidget(self.buttonBox, 5, 0, 1, 2) + + + self.retranslateUi(WandererDialog) + self.buttonBox.accepted.connect(WandererDialog.accept) + self.buttonBox.rejected.connect(WandererDialog.reject) + + QMetaObject.connectSlotsByName(WandererDialog) + # setupUi + + def retranslateUi(self, WandererDialog): + WandererDialog.setWindowTitle(QCoreApplication.translate("WandererDialog", u"Wanderer Configuration", None)) + self.label_name.setText(QCoreApplication.translate("WandererDialog", u"Name:", None)) + self.label_url.setText(QCoreApplication.translate("WandererDialog", u"URL:", None)) + self.lineEdit_url.setPlaceholderText(QCoreApplication.translate("WandererDialog", u"https://your-wanderer-instance", None)) + self.label_map.setText(QCoreApplication.translate("WandererDialog", u"Map slug:", None)) + self.lineEdit_map.setPlaceholderText(QCoreApplication.translate("WandererDialog", u"Map identifier from the map URL", None)) + self.label_token.setText(QCoreApplication.translate("WandererDialog", u"API token:", None)) + self.label_help.setText(QCoreApplication.translate("WandererDialog", u"

Generate the token in Wanderer under Map Settings \u2192 API. About Wanderer

", None)) + # retranslateUi +