6 Commits
Author SHA1 Message Date
MarekWoandClaude Opus 5 8f1334ee00 fix(map): reject impossible advert coordinates before they blank the map
A tester's cached-contacts map came up empty and Path Analyzer drew routes
across the globe. Two of his 2131 cached contacts carried garbage positions
from corrupted adverts - lat 1642 / lon -323 and lat 309 / lon -1768.

Leaflet clamps latitude when projecting but not longitude, so fitBounds()
spanned 1797 degrees, dropped to zoom 0 and centred on lon -870; all 2273
real markers were rendered several world-widths outside the viewport.
Reproduced against his DB: zoom 0, bounds -1431..-309. The device-only view
was fine because both bad rows are cache-only.

Four layers, so no single miss can blank a map again:

- app/geo.py: one definition of a usable coordinate, applied on write in
  Database.upsert_contact() so the cache stops storing garbage, and on API
  output so a cache poisoned by an older build heals itself with no
  migration. Corrupted advert types (61, 126, 152, 210 seen in the wild)
  are logged but kept raw - they already render as UNKNOWN.
- static/js/geo-utils.js: hasValidGps()/withValidGps()/fitMapToPoints()
  replace eight copies of the same check across six files. Path Analyzer
  had a looser variant that also accepted lon == 0, which put nodes off
  west Africa and stretched routes across the map; it now shares the rule.
- Per-marker try/catch and a bounds validity check, so an entry that still
  slips through costs its own pin rather than the whole map.

Verified against the tester's DB in a container: cached map 2273 markers,
lon span 1797 -> 19 degrees, centred on Poland; the route that drew the
world-spanning line (msg 29499, token 72AF) now renders as a real
Krakow-Kielce path with the bad hop left unresolved. Live local instance
regression-checked on all five map pages. tests/test_geo.py: 43 passed;
full suite 290 passed, 2 pre-existing failures unrelated to this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 11:35:53 +02:00
MarekWoandClaude Opus 5 4a5e70abca feat(paths): add a "Set Direct" button next to "Restore FLOOD"
The path dialog could send a node back to FLOOD or push a configured
path, but Direct — the firmware's third mode, zero hops — had no button;
getting there meant clearing the path from the Console.

A new POST /api/contacts/<key>/paths/set_direct sends an empty path hex,
which makes the firmware store out_path_len = 0, the state
_format_path_display() already renders as "Direct". Configured paths are
untouched, same as Restore FLOOD.

The button is wired in both places paths are managed: the DM contact info
dialog and the My Repeaters path modal. Both button rows get flex-wrap so
the third button wraps instead of overflowing at 360px.

Verified against the local test device: the endpoint moved a repeater
from 5E34→EE6C to Direct and back, and both dialogs render correctly in
en/pl at 1280px and 360px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 08:57:42 +02:00
MarekWoandClaude Opus 5 8224128da7 fix(repeaters): let a long path wrap in the list instead of being cut off
Same defect as the management header, different symptom: the row put the path
in a .text-truncate line, so on a phone a multi-hop path ended in an ellipsis
and the remaining hops were unreadable. The path line now wraps (per hop, via
the same zero-width space) and keeps its own row width; the logging-in and
not-on-device variants are short and stay truncated as before.

The add-repeater picker and the current-path line in the path modal show the
same string and got the same treatment.

Verified at 360 px against the running container with stubbed API data: list
row, picker item and modal each wrap to two lines with zero overflow, and the
page reports no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:48:14 +02:00
MarekWoandClaude Opus 5 93c74d150f feat(i18n): translate the My Repeaters panel (stage 3a)
~100 keys across repeaters.html and repeaters.js: the list, the add picker, the
password/login flow, the path editor, the map picker, and every toast.

Boundary call made here and written into docs/translations.md: "path" is
translated ("ścieżka"), because it is a word the user reads, not a value the
device reports. The mode names it can hold — Flood, Direct, FLOOD — stay as-is,
as do hop hex, repeater names and login roles. The rule is now stated explicitly
in the translator guide, since "Skonfigurowane path" is exactly the kind of
half-translation that makes a UI worse than leaving it in English.

Removed a third copy of relative-time formatting. Stage 0b merged app.js and
dm.js's formatTime but I grepped for the wrong names and missed
formatRelativeTime; repeaters.js had its own, with a "Never" branch that was
unreachable because its one call site already guards a falsy timestamp. It now
uses the shared formatTimeAgo. contacts.js and dm.js still carry their own
copies — noted in place, folded in with stages 5 and 6.

Two checker fixes, both found by this slice:
- Keys chosen inline, tHtml(x ? 'a.b' : 'c.d'), were invisible to the scanner.
  That produced false "unused key" warnings and would have silently hidden a
  typo in either branch. Added a second pattern for that shape.
- The generic en/pl diff caught two real misses I had made — the page <title>
  and the toast header, both still reading "My Repeaters". Swept every other
  template for the same pattern; the remaining ones belong to later stages.

Verified: checker clean, and the diff now reports every comparable string on
/repeaters as changed between en and pl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:49:13 +02:00
MarekWoandClaude Opus 5 2ec2f461e2 feat(i18n): add UI translation infrastructure (stage 0)
Groundwork for translating the interface. No strings are extracted yet — this
stage only adds the mechanism, so the UI is byte-identical in English.

Translations are one flat JSON file per language. An admin can add a language by
dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in
Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file
overrides a built-in one of the same name. Built-ins: en (source) and pl.

How it works:
- app/i18n.py loads and merges catalogs over en.json, so per-key English
  fallback is baked in server-side and the JS runtime needs no fallback logic.
  Catalogs are fingerprinted with stat() per render, so a dropped-in file is
  live on the next refresh rather than the next restart — which matters,
  because restarting drops the device connection for up to 60s.
- Template text renders server-side via t()/t_html()/tn() from inject_globals(),
  so the first paint is already correct. That context processor covers every
  render_template() in the app, including the six standalone iframe pages, so
  routes/views.py needed no changes.
- JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared
  by all 8 entry points. The hash is in the path, not a query string, so
  intermediary caches and the service worker bust reliably.
- Language = per-browser mc_lang cookie over a server-wide DB default. The
  cookie is what makes iframes work: they are same-origin, so they send it
  automatically and the existing modal-open reload wiring needs no changes.

t() deliberately does not escape — Jinja autoescape handles that, and escaping
here would double-escape every French apostrophe. t_html()/tHtml() escape their
params but trust catalog markup.

Also fixed along the way:
- save_ui_settings() replaced the whole settings blob instead of merging, so a
  language-only POST would have wiped the toast settings. It worked before only
  because the one form always submitted every key.
- `const t = document.getElementById(...)` in populateUiSettingsForm shadowed
  the global translation helper; same for `var t` in six theme IIFEs.
  scripts/i18n_check.py now fails the build if that pattern comes back.
- Removed app/templates/contacts.html, dead since the contacts pages were split
  (no render_template reference anywhere), and two stale CSS comments.

Verified locally: catalog route headers/security, drop-in without restart,
malformed catalogs logged and skipped rather than offered in the picker,
cookie propagation into iframes, and 360px layout. Python and JS agree on
interpolation, escaping and Polish plural categories.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:29:41 +02:00
MarekWoandClaude Fable 5 ca2a6bacf9 feat(repeaters): My Repeaters panel (stage 1)
New full-screen panel (main menu / FAB) for repeater administration:
- repeaters table (saved per-pubkey admin password, login metadata);
  plaintext per observer_brokers precedent (single-user LAN app)
- REST /api/repeaters: list merged with device contact truth, add
  (device REP contacts only), set/clear password, remove, login
- device_manager: _repeater_lock serializes all repeater ops (companion
  firmware has a single pending-request slot); repeater_login now
  filters LOGIN_SUCCESS by pubkey_prefix and captures is_admin/
  permissions into an in-memory session store
- login timeout UX: wrong password and unreachable repeater are
  indistinguishable (firmware stays silent) - error message names both
- panel UI: add-picker, password modal (remembered password), per-
  repeater path editor reusing /api/contacts/<pk>/paths + Leaflet
  repeater map picker (adapted from the DM panel)
- meshcore pin bumped to >=2.3.7 (send_login_sync era API, fixed
  LOGIN_SUCCESS/LOGIN_FAILED parsing; container already ships 2.3.7)

Stage 2 (management panel with Status/Telemetry/Neighbors/CLI/
Settings/Actions tools) follows after user review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:55:44 +02:00