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
+