Signed release build of the textZoom change from 09dcf26, plus the
download table it invalidates.
Verified on the artifact rather than on trust: classes.dex references
setTextZoom, the manifest reports versionName 1.2, and the signing
certificate is SHA-256 42:58:57:B3:...:D2:30 -- the same key as 1.1, so
it installs over the previous version instead of forcing a reinstall.
User confirmed on-device that nothing wraps any more.
Size is unchanged at 4.6 MB (4.61 MiB), so only the version and hash
rows move. android/README.md documents the release steps but pins no
version of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's WebView scales text by the system font-size setting; Chrome
ignores it and uses its own accessibility preference. With the system
font one step above default, the wrapper rendered every string ~20%
larger than the website at an identical layout width -- the brand
truncated to "mc-web...", message bodies wrapped early, and the footer
broke apart. Pin textZoom to 100 so the wrapper matches the browser.
The footer was fragile independently of that. Status, region badge and
the "Updated:" time sat in a justify-content-between row with no wrap,
so once they exceeded the width each item broke internally instead of
the row reflowing -- a dot with "Polaczono" stranded below it, a time
split from its own label. Longer translated labels ("Aktualizacja:" vs
"Updated:") hit this well before English did. Adding flex-wrap plus
text-nowrap on the items makes them reflow as whole units; .badge
already carries white-space: nowrap, so it needs nothing.
Verified in Chrome against the local container, pl locale, region badge
forced visible, at 412/360/320 CSS px and with a 1.2x text-only override
that reproduces textZoom: before/after screenshots show the reported
breakage and a clean two-line reflow respectively, with no horizontal
overflow. At full width the change is a no-op.
Rides along in the already-pending, never-built wrapper 1.2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A catalog whose filename does not match ^[a-z]{2}(-[A-Z]{2})?$ is ignored
silently by available_languages() — no error, no log line, no entry in the
dropdown. pt-br.json, hu_HU.json and hungarian.json all fail that way, and the
guide gave no way to find out. Verified against the running container: hu.json
appeared as "Magyar" with three keys and the rest falling back to English,
while pt-br.json, hungarian.json and a de.json with a broken body were all
skipped without affecting the app.
Also spells out that app/translations/ lives inside the image and needs a
rebuild, so the config-directory drop-in is the one to reach for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An end-of-stage-9 sweep over every JS file found ~85 user-facing strings that
stages 3-5 never extracted, because their browser diffs only ever opened the
states those pages start in.
Most of it is repeater-manage.js: the entire login flow (prompt, progress,
failure hint, password modal), the reboot and radio-change confirmations, the
"Updated Xs ago" labels and fmtHeardAgo, the Status/Telemetry/Neighbours/CLI
tool chrome and the Status table's section headings and row labels. Plus
contacts.js (auto-cleanup status, push/move confirmations, bulk progress
counters, QR error), path-analyzer.js (map popups, table tooltips, the map
toggles) and two strings in repeaters.js.
Row labels in the Status table go through t(), not tHtml(): statusSection()
already runs esc() over the title and every label, so tHtml would escape twice.
Protocol terms stay English as before: flood/direct in the packet counters,
the CLI quick commands and the Cayenne LPP sensor type names.
Verified with F:\tmp\pwverify\i18n-stage9d.js. Two strings come back identical
and both are correct: "Hop" is glossary, and "Repeater not in list" is backend
error text, which is out of scope by decision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Last slice of app.js: the update check/modal/progress, the browser
notification body (now plural-aware via tn), global search, the mentions
autocomplete, the archive dropdown, mark-all-read, and the four delete
confirmations plus two form errors that 9b's toast sweep walked past.
app.js is done. What is left in it is deliberate: the resync reason strings
(console only), "SNR: x dB", the node roles, and the RADIO_PRESETS labels —
region presets stay English per the glossary.
Verified with F:\tmp\pwverify\i18n-stage9c.js: the search modal in three
states, the update modal, the mentions empty row, and the notification /
confirm strings that never reach the DOM, resolved directly in both
languages. Every comparable string differs; the 80 identical ones in the
search hit list are message bodies and sender names.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Second slice of app.js: the Device modal (Info rows, Stats table, Share tab),
Settings device/radio saves and the shared "Settings saved" toasts, contacts
cleanup, device commands, the PWA notification toasts, and everything app.js
renders into the Region, Analyzer and Observer registries plus the per-channel
region picker and the Backup/Optimize modal.
Radio parameter names stay English per the glossary (Bandwidth, Spreading
Factor, Coding Rate, Multi Acks), as do the node roles in the type column; the
generic row labels around them are translated. The five `x.error || 'Failed to
save'` fallbacks are normalised onto the existing common.save_failed rather
than minting a near-duplicate key.
Verified against the local container with F:\tmp\pwverify\i18n-stage9b.js,
which drives the three Device tabs, five Settings tabs and the Backup modal in
both languages: 12 identical strings, all of them device names, hex keys,
MQTT topic literals or a backup filename.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First slice of app.js: the map popups, resync and channel toasts, the message
bubble (action tooltips, SNR/hops/route meta, echo badge), the route popup,
channel management, and the two joins plus the image-preview modal in
message-utils.js.
Six keys shared with the DM panel move dm.* -> chat.*, the namespace stage 6
opened for chrome the two chats have in common; dm.js follows.
The action-button injection used to find already-rendered buttons with
[title="View in Analyzer"], [title="Edit message"] and [title^="Ignore"].
Those titles are translated now, so every lookup would have missed and the
meta refresh would have appended a second analyzer button on each pass. They
match .btn-msg-analyzer / .btn-msg-edit / .btn-msg-ignore instead.
Verified against the local container: F:\tmp\pwverify\i18n-stage9a.js reports
one identical string across the message list, route popup and channel modal
(an echo badge that is pure data), and i18n-stage9a-buttons.js confirms three
forced meta refreshes leave at most one of each button per bubble.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stage 5a translated the contacts pages but the browser diff was only run
against /contacts/existing, so the other three kept most of their page
headers, action buttons and confirmation modals in English: page titles,
"Add Contact", "Pending Contacts", Refresh, Filters, Approve/Ignore and
both batch-confirmation dialogs, plus Advanced Filters, Preview Cleanup
and the cleanup confirmation on /contacts/manage. Most of the keys they
needed already existed from 5a - the template sites were simply never
wired to them.
Three spinners in the Settings modal (Regions, Analyzer, Observer lists)
were missed in 8b for the opposite reason: their containers are excluded
from the stage-8 browser check as JS-populated, but the spinner inside is
server-rendered, so excluding the container hid it.
Root cause of both, and the reason for the new tool: a browser diff only
sees what is visible on the page it is pointed at. Add
i18n-template-diff.py, which fetches each entry point's HTML in en and pl
and compares text nodes directly. No browser, no per-page script, and it
covers closed modals and every page in one run - it found all of the
above in a single pass. It cannot see anything JavaScript writes, so it
complements the Playwright scripts rather than replacing them.
Every server-rendered template in the project is now translated. The
whole-project run reports 11 identical strings, all correct: the device
name, version strings, the language names (which are supposed to match),
"1 hop" (identical in Polish) and the glossary terms Hash, HB, Repeater.
Catalog: 760 -> 781 keys, pl at 100%.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Observer, Notifications (stage 8b)
Finishes the Settings modal, and with it every template in the project.
The Quick Access placement table is 13 rows that differ only in the item
they name, so it is driven by one template key plus the item labels the
menu and FAB column already have - t('settings.appear.placement_aria',
item=t('dm.title')) - instead of 13 near-duplicate keys and 26 copies of
"Quick Access"/"Main Menu".
That does normalise two aria-labels: "DM placement" and "Contacts
placement" become the full item names, matching what the row already
reads and what the other eleven already did. They are screen-reader-only,
so nothing visible changes; the alternative was keeping two keys alive
forever to preserve an abbreviation.
Paragraphs with a link or <code> inside stay whole rather than being cut
around the markup - the Regions and Observer intros are single t_html
values, so a translator moves the link where their grammar wants it
instead of translating two half-sentences.
Also in app.js: updateNotificationToggleUI() writes all four badge states
(Unavailable / Blocked / Enabled / Disabled) over the server-rendered one.
Catalog: 715 -> 760 keys, pl at 100%. The Settings diff now reports two
identical strings across all ten tabs, both correct: docs/translations.md
and meshcore-packet-capture, a path and a format name. No horizontal
overflow at 360px on any tab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modal shell and the first four tabs, plus all ten tab labels: the tab
bar is one block, so splitting it across two commits would have meant
editing the same lines twice. 8b fills the panes behind the other six.
Radio parameter names stay English - Bandwidth, Spreading Factor, Coding
Rate are what the firmware, the CLI and the forums call them, the same
call rptmgmt.set.radio_note already made. Latitude, Longitude and "Pick
from map" reuse the rptmgmt.set.* keys rather than getting a second
translation that could drift from the repeater form.
"Auto-close after (s)" and "Don't close automatically" appear in both the
Group Chat and Interface tabs with identical English, so they share one
key; only their tooltips differ, and those are separate keys.
Also in app.js: populateRadioPresets() rebuilds the preset <select> and
was writing "Load preset..." back in English over the translated option.
Catalog: 651 -> 715 keys, pl at 100%. Verified with a new
i18n-stage8-settings.js that walks all ten tabs (and both Device pills),
written to cover 8b too - the six untranslated tabs show up in its report
until their slice lands, which is the point.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Manage Channels, region scope picker, Share Channel, Device Info,
analyzer registry (edit + chooser), Observer MQTT broker editor,
coordinate picker, quote dialog, map, update, backup and search.
The Settings modal (base.html ~458-1147) is still untouched - stage 8.
Where the two share markup, this commit anchors on the container id
rather than the markup: the "Loading..." spinner appears six times in
base.html and only three of them are in this slice.
Also in app.js, on the same rule as 7a - the writer that replaces a
string this slice keyed: the analyzer and broker modal titles (they
swap Add/Edit on open), the map modal title, the coordinate picker
label, and the database size line under Optimize.
Three real misses came out of the browser diff, none of which the
checker could see:
- Cancel/Save in the analyzer and broker editors were never keyed. They
only surfaced once because the diff dedupes by text, so a single
report line stood for both modals.
- The database size under Optimize is written by JS on open, so the
translated server-rendered value was replaced a moment later.
- All three Device Info tabs render their bodies from app.js. Excluded
from the check and left whole for stage 9 rather than translating the
Share tab alone.
Left English on purpose: FTS5 query examples (hello OR world, hell*) -
the syntax is typed verbatim in any language, only what each one means
is translated - plus mqtt.example.com and the {packetHash} token.
Catalog: 573 -> 651 keys, pl at 100%. Verified with a new
i18n-stage7b-modals.js that opens each modal through the Bootstrap API
instead of its real trigger, since several are only reachable from a row
or message that may not exist on a given device.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers the navbar, the offcanvas main menu, and index.html: the channel
sidebar, filter bar, composer, status bar and the FAB column, plus the
six fullscreen panel modals, whose headers now reuse each panel's own
title key (dm.title, contacts.title, console.title, repeaters.title,
pa.title, logs.title) instead of repeating the words.
The Settings modal (base.html ~458-1147) and the other modals in
base.html are untouched - stages 8 and 7b.
Also translated in app.js: updateStatus(), updateLastRefresh(), the FAB
collapse toggle, the filter bar's "no matches" panel and
updateRegionIndicator(). The rest of app.js stays for stage 9; the line
drawn here is "does it overwrite a string index.html just got a key
for". Without them the status bar renders Polish server-side and flips
back to English a second later, which would make the slice impossible to
verify - a miss and a stage-9 leftover look identical.
"Filter messages" (menu) and "Filter Messages" (FAB) keep separate keys
even though the Polish is identical, so this commit changes no English
text, only where it lives.
Catalog: 526 -> 573 keys, pl at 100%.
Verified with i18n-diff.js and a new i18n-stage7a-chrome.js that opens
the offcanvas menu at both viewports - like the DM modals, a closed
offcanvas is invisible to the plain diff. Two tooling fixes came out of
it, both in F:\tmp\pwverify:
- i18n-diff.js checked visibility on the text node's parent only, so
every closed modal's contents landed in the report: 275 flagged lines
on the main chat, 13 after walking the ancestor chain. It was noise,
never a false pass, but it is the kind of noise that trains you to
skim the list.
- browser.js now centralises the Chrome launch. The translate bubble was
held off by one flag copy-pasted into each script; it is now flags plus
a persistent profile with translation disabled in Preferences, which is
the part that survives Chrome renaming the feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Covers app/templates/dm.html and app/static/js/dm.js: the sidebar, the
searchable conversation selector, the composer, message bubbles and their
delivery meta, the Contact Info modal, path management, and the repeater
list/map pickers.
The DM panel's path management and repeater pickers are the code the My
Repeaters panel was adapted from, so its strings reuse the repeaters.*
keys added in stage 3a rather than duplicating them. Shared chat chrome
(filter bar, FAB titles, composer placeholder, status bar) goes to a new
chat.* namespace so index.html and app.js can reuse it in stages 7 and 9,
and console.status.* is promoted to common.connected/disconnected/
connecting now that two status bars want the same three words.
formatRelativeTimeDm() was the last of the four local relative-time
implementations; it is gone, folded into formatTimeAgo() from
datetime-utils.js. The contact-info row uses the long form to match the
contacts page, the map popup the short form to match My Repeaters.
Also fixed along the way, both found by grepping this slice's shared
strings against the already-translated panels:
- repeaters.js reset the map picker label from JS in three places with a
hardcoded English string, so the header translated on load and flipped
back to English the moment the picker opened (missed in stage 3a).
- tHtml() on a plural key always renders the "one" form, because only
tn() passes the count to the resolver. That made the repeater picker's
shared-prefix tooltip read "3 repeater ma ten prefiks". Fixed in both
callers and now an error in scripts/i18n_check.py, which had no way to
catch it.
Left English on purpose: Flood/Direct/FLOOD and the SNR labels (glossary),
and the 'Device path' label written to the DB on import - it is stored
data, so translating it would freeze one language into the row.
Catalog: 471 -> 526 keys, pl at 100%. Verified against the local
container with i18n-diff.js plus a new i18n-stage6-dm.js that drives the
panel through each modal, since the diff tool cannot see hidden ones.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~100 keys, finishing stage 5. Catalog now 471 keys, pl at 100%.
Covers the cleanup form's validation and progress messages, the protect/block/
ignore/approve button set, bulk approve and ignore with their partial-failure
reports, the QR and URI add flows, and every toast in the file.
Folded in the fourth and last copy of relative-time formatting. contacts.js had
its own formatRelativeTime with more granularity than the shared helper — months
and years instead of falling back to a date past a week. Rather than drop that,
formatTimeAgo gained a `long` option that keeps counting, so a stale advert still
reads "3 months ago" / "3 miesiące temu". dm.js's copy is the only one left and
goes with stage 6.
Two things worth recording about verification. I first checked these pages only
for leaked catalog keys, which came back clean and told me nothing — the page was
still full of English. Running the actual en/pl diff turned up the page heading,
Back and Refresh, all four sort options, the "Last advert:" prefix, the activity
tooltips, "Protect" in its second render site, and the hop counter. The lesson is
that a key-leak check proves the keys you used are real, not that you used them.
And the diff's raw output on this page is 2000 lines because every contact name
and public key is legitimately identical in both languages. Filtering those out
made the eight real misses obvious; the per-page exclusion lists exist for exactly
this and the contacts entries now carry them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54 keys across contacts_base.html and the four contacts-* pages: the shared
delete-confirm modal and toast, the map modal, the three nav cards, the whole
cleanup form, the filter bars, and the add-contact URI/QR/manual forms.
Node types stay English throughout — the COM/REP/ROOM/SENS codes and their
"(Companion)" / "(Room Server)" expansions. The codes are also what the JS filters
compare against, so translating the labels would be a step toward breaking the
type filter.
contacts.js is the other half of this stage and follows separately; it is 2771
lines and carries roughly twice the string count of these five templates.
The hit-count guard earned its keep again: it refused to touch contacts-add.html
and contacts-pending.html because "Name:", "Key:", "Type:", "Cancel" and the
close aria-label each appear two or three times. All were genuinely the same
label reused, so they share a key — but the script stopped rather than guessing,
and the three files it had already processed were left correctly applied.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~90 keys across path-analyzer.html and path-analyzer.js. Catalog now 333 keys,
pl at 100%.
Covers the four view buttons, the whole filter bar, all four table layouts with
their explanatory column tooltips, the map sidebar, and the per-view counters.
The counters were the fiddly part: four of them, each combining a filtered and an
unfiltered form, and each mixing two independently pluralised nouns —
"12 repeaters (340 of 977 messages)". They are now composed from tn() parts, so
Polish gets "12 repeaterów (340 z 977 wiadomości)" with the right form on both.
Not translated, deliberately: message text, sender names, channel names, packet
and repeater hashes, SNR figures, and the hop chains — all device or user data.
The diff tool's exclusions for this page name the four table bodies that carry it.
The diff caught four filter options I had skipped (1-byte / 2-byte / 3-byte /
2/3-byte) because my template edit only replaced the "Any HB" line above them.
Its one remaining hit is "1 hop", which is correct: Polish "one" is identical to
the English singular here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~110 keys across repeater-manage.html and repeater-manage.js, finishing stage 3.
Catalog is now 243 keys, pl at 100%.
The interesting part is the three declarative schemas — TOOLS, SETTINGS_SECTIONS
and REPEATER_ACTIONS — whose title/label/help/note/desc fields held English
prose. Those fields now hold catalog KEYS, resolved with t() at each render site,
with a comment on each array saying so. The firmware `key` next to them
(radio.rxgain, advert.interval) is untouched: it is a CLI parameter, not text.
That created a checker gap worth naming: a key reached as t(f.label) is invisible
to a scanner looking for t('literal'), so all 57 of them would have been reported
unused. The scanner now also treats a bare string literal that exactly matches a
catalog key as a reference. It only relaxes the unused-key warning — missing-key
errors and the markup lint still come from real call sites, so a typo in a schema
key still surfaces, just at runtime rather than in the checker.
Trimmed the do-not-translate glossary from 19 terms to 10. It fired four
warnings on this slice and three were wrong: "direct neighbours" is an adjective,
not the Direct mode; "telemetria" and "czujniki" are simply the Polish words.
A warning you are supposed to ignore teaches you to ignore all of them, so the
list now holds only jargon whose native translation would stop an operator
matching the UI against firmware output — flood, hop, advert, ACK, RSSI, SNR,
LoRa, MQTT, pubkey, repeater. The rest stayed as guidance in the translator doc.
"flood advert" kept the word in Polish for that reason.
Bulk edits were applied by a script that asserts an exact hit count per
replacement and writes nothing if any count is off — which caught one case where
I had miscounted 7 occurrences as 6.
Verified in the browser: all 57 schema-resolved keys resolve in both languages
(none falls back to its own key), and the page title and toolbar render Polish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
~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>
14 keys: title, connection status, the help hint, history and clear tooltips,
the command placeholder, the scroll-to-latest button, and the two error wrappers.
Everything the Console actually prints stays English, which is most of what is on
screen: the `help` screen, command names, device replies, `(no output)`'s
surroundings. data.output and data.error are composed by the backend and the
firmware, so only the wrapper around them is translated — "Error: {error}"
becomes "Błąd: {error}" with the English message intact. The 'help' inside the
hint text is the literal command name and is marked as such in the template.
"polecenie" over "komenda" for command, matching how Polish software normally
renders CLI terminology.
Also adds F:\tmp\pwverify\i18n-diff.js — a reusable check that renders a page in
both languages and reports every visible string that came out identical. Each hit
is either a missed extraction or something meant to stay English, so it turns
"switch to Polish and hunt for English words" into a list to review rather than a
squint. It pays for itself from stage 3 on, where the slices get large.
Its output for this slice: /console has exactly one identical string, the user's
own device name; /logs has eight, all Python logger names in the module filter.
Both correct.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First vertical slice: /logs chrome in English and Polish. Small on purpose —
~13 strings — but it exercises every part of the mechanism end to end: the head
include, the catalog route, the cookie, server-rendered t()/tn(), client-side
t()/tHtml()/tn(), a JS-rendered plural, and a rebuilt <select>.
Translated: title, pause/clear tooltips, the two "All ..." filter options, the
search placeholder, the reset tooltip, the loading and load-failed messages, and
the entry counter.
Deliberately NOT translated, per the boundary in docs/translations.md: the log
lines themselves and the DEBUG/INFO/WARNING/ERROR level names. Those are
protocol-side identifiers — the level filter also compares against them, so
translating the labels would be a step toward breaking the filter. Marked with a
comment in the template so the next person does not "fix" it.
The counter is the interesting case: English has two plural forms and Polish
four, so "100 entries" is "100 wpisów" while "2 entries" is "2 wpisy". Rendered
by tn() through Intl.PluralRules on the client and the matching rule table on
the server, which agree.
Also fixed in scripts/i18n_check.py: it warned when a markup-free value was used
via tHtml(), which is wrong and actively harmful advice. What tHtml() buys at an
innerHTML sink is param escaping, not markup support — the warning would have
pushed people toward t() in exactly the place where t() is the XSS hazard. The
error in the other direction (markup used via t()) stays. Output streams are now
forced to UTF-8 so `--missing pl > todo.txt` produces a usable file on Windows
rather than mojibake.
Verified in the browser: every chrome string differs between en and pl, level
names unchanged, no catalog key leaked into the page, the count stays translated
after filtering, and the filter bar wraps without overflow at 360px in Polish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Prerequisite for string extraction. formatTime() existed twice with subtly
different behaviour, and both hardcoded 'Yesterday ' — a string that would have
survived a naive extraction pass and stayed English forever.
app/static/js/datetime-utils.js now owns the today/yesterday/older logic, with
options for the two variants that actually differed: app.js needs an absolute
date in archive views, dm.js needs the short "5 Aug" form because DM rows are
narrower. Both keep a thin local formatTime() wrapper, so their ~15 call sites
are untouched. formatTimeAgo() moves over as-is (it only ever existed in app.js).
repeater-manage.js's fmtInt() no longer forces toLocaleString('en-US'), so
thousands separators follow the reader's locale. Kept as a function declaration
rather than a const alias so it stays hoisted, like the code it replaced.
Policy, documented in the module header: numeric formatting follows the BROWSER
locale, only the words are translated. Tying the clock to the UI language would
flip a Polish operator to "09:53 AM" the moment they switched the interface to
English, and mesh operators want 24-hour time whatever language the menus are in.
First five real catalog entries: common.yesterday, just_now, minutes_ago,
hours_ago, days_ago. days_ago exercises the plural machinery — Polish needs
"1 dzień temu" / "3 dni temu" / "5 dni temu" where English has one form.
Verified in the browser in both languages: all four helpers global, both
formatTime wrappers still routing through the shared core, Polish plural
categories correct, and the clock and number formats provably unchanged by the
UI language. Live chat timestamps render identically to before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
The dropdown already rendered a .sidebar-unread-badge element, but the
styling rule was scoped to .channel-sidebar-item, so on narrow screens the
count fell back to plain text in the row's own colour. Extend the selector
to .channel-selector-item so both views share one badge style.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three tabs open made every endpoint answer in ~20s, which two sessions read as
contention on a shared server-side lock. It wasn't the server. With 3 tabs open,
/health - which touches neither the database nor the device - measured a 14.7s
median from inside a tab and 11ms from a client outside the browser at the same
instant. The server was idle throughout.
Socket.IO clients pinned transports: ['polling'], upgrade: false, so each tab
held one HTTP connection open for its whole lifetime. Browsers allow six
concurrent HTTP/1.1 connections per origin, shared across every tab, so three
tabs consumed the pool and everything else queued in the browser waiting for a
free connection. /proc/net/tcp in the container confirmed it: pinned at exactly
6 established connections, unmoving.
The pin dates from 1d47c9c, when werkzeug had no WebSocket support and every
upgrade attempt returned HTTP 500. python-engineio==4.8.1 (pinned five weeks
later, in d3590f9) pulls in simple-websocket, which fixed that; the workaround
had outlived its premise. Drop it and use the default transports, which open on
polling and upgrade. A WebSocket is not part of the HTTP pool, so the pool is
released. Where the upgrade is blocked - a proxy that drops the Upgrade header -
the client stays on polling by itself, which is exactly today's behaviour.
Measured with 3 tabs, in-page medians: /health 14664ms -> 12ms, /api/status
19282ms -> 64ms, both now matching what the same probes read from outside the
browser. All three tabs report transport "websocket", server pushes still
arrive over it, and the log no longer fills with "Session is disconnected"
(0 occurrences, 0 tracebacks across the run).
Also corrects the earlier diagnosis in the docs: the per-endpoint timings that
looked like a lock were measured request->requestfinished in the browser, which
includes connection-queue time, so every endpoint flattened to the same figure
regardless of its own cost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/api/status is polled by every open page (on load, every 60s, and on
visibility resume) and cost ~266-450ms in-container while doing no device
I/O at all — check_connection() is just an attribute read, and /health
returns in ~1ms, so all of it was SQLite.
Two calls were over-fetching:
- get_stats() ran COUNT(*) over 11 tables to answer a question about 2.
COUNT(*) FROM echoes alone was ~200ms over 19k rows and the result was
discarded. That table only grows, so the endpoint kept getting slower.
- get_channel_messages(limit=1) was a SELECT * fetching every column,
raw_packet included, to read one timestamp. ORDER BY timestamp has no
usable index (idx_cm_channel_ts leads with channel_idx), so the plan
was a full SCAN through two temp B-trees. MAX(timestamp) is served off
that index as a covering scan instead.
Replaced with Database.get_status_summary(): three scalar subqueries on
one connection. ~371ms -> ~46ms for the DB work; endpoint median ~57ms
in-container, ~78ms from the page. Response verified byte-identical.
This does not address the multi-tab congestion — at one poll per tab per
60s /api/status was never that cause — but it removes a recurring cost
and re-baselines the probe those measurements are taken with.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Testing after the batching fix showed three tabs still push /api/status from
~600ms to a ~13s median, from a different cause (every endpoint degrades
together under load, so a shared lock rather than a request storm). The entry
claimed multiple windows were no longer a problem; scope it to the load
reduction that was actually measured.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every incoming echo triggers a sweep of the whole rendered message list, and
refreshMessagesMeta() awaited one /api/messages/<id>/meta per message inside
the loop. Messages that never gain a route (nothing heard them) never stop
qualifying for the sweep, so the same ~180 messages were re-fetched every few
seconds: 7,500 requests in nine minutes on a single tab, each opening its own
SQLite connections. The single-threaded werkzeug server — the same one
production runs — had no room left for anything else, so the UI hung on
"Loading messages..." / "Connecting..." while the device was in fact connected.
Add GET /api/messages/meta?ids=... resolving the whole sweep with a handful of
queries, batching the row and echo lookups, and have the client collect ids
first and fetch them in chunks. The per-message endpoint stays for forced
single refreshes; both now share _build_message_meta() and the existing
_build_channel_secrets / _get_row_pkt_payload helpers, so the payload is
unchanged (verified byte-identical against the old response).
Measured on the local container, 500 messages rendered / 181 needing meta:
one 273 ms request in place of 181 sequential ones at ~82 ms each.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat view only ever grew by socket push, so anything that arrived
while the connection was down was never drawn. Android tears the
connection down behind a locked screen, and the wrapper keeps the same
page alive for days, so the list stopped at the last message that got
through until the app was force-stopped. A browser tab hid the same bug
by reloading the page on resume.
Every way back from a gap now re-reads the list from the server: the
socket reconnecting, the page becoming visible after more than a glance
away, a heartbeat noticing its own tick arrived far too late (the page
was frozen), a new Refresh item in the menu, and window.__mcAppResumed,
which the wrapper calls from onResume since a WebView is not guaranteed
to report the page as hidden at all. Direct messages get the same
treatment.
Verified in Chrome against the local container: all five triggers fire a
resync, with no page errors and the list intact afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also corrects the app-vs-PWA table in the user guide: it claimed QR
scanning and file downloads were unavailable in the Android app, which
contradicted the Android App guide from the start - both have always
worked there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The signed release build of the notification work from 954e99b, signed
with the same key as 1.0 (certificate SHA-256 425857b3...d230), so it
installs straight over the previous version and users keep their saved
server address.
Verified against the packaged APK: versionCode 2 / versionName 1.1,
POST_NOTIFICATIONS present, and assets/notification_shim.js included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Android's WebView ships no Web Notifications API, so window.Notification
was undefined, mc-webui detected that and greyed its toggle out as
"Unavailable". The app also declared no POST_NOTIFICATIONS and created no
channel, so it never even appeared in Android's notification settings.
A shim injected at document start puts window.Notification back and
forwards it to a @JavascriptInterface bridge that posts through Android's
NotificationManager. mc-webui itself is untouched - the page keeps using
the standard API.
- onPageStarted is early enough: mc-webui reads the permission on
DOMContentLoaded, a whole parse and script pass later
- web permission states map onto Android's, with
shouldShowRequestPermissionRationale separating "ask again" from
"blocked for good" after a refusal
- tags replace notifications the way the web API expects; a tap returns to
the running app (singleTop) and fires the page's onclick
- notifications only arrive while the process is alive, same as the PWA
versionCode 2 / versionName 1.1. Verified: debug and release both build
clean (lintVitalRelease included), shim and drawable land in the APK, and
the shim's contract is covered by a Node harness against a fake bridge.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the debug APK with a release build signed with the project key
(CN=Marek Wojtaszek, valid to 2081, v1+v2 schemes, not debuggable), so
every later version installs over this one instead of forcing a reinstall.
Docs catch up with what the app can now do: QR scanning works on https
instances, downloads land in the phone's Downloads folder, and the address
form is a non-destructive screen you can reach deliberately. New SHA-256,
size, permission list and the signing fingerprint to verify against.
Also silences the Kotlin warning for the deprecated shouldOverrideUrlLoading
overload kept for Android 5.x - source-only, the APK is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wrapper is what users sideload, so its source belongs next to the APK -
they can read what they install, or build it themselves.
Behaviour fixes on the way in (APK rebuild pending):
- The saved server address survives. Back on the first page and connection
errors used to delete it, so a stray tap or a moment without signal meant
typing the address again; the form now opens pre-filled and only a save
replaces what is stored. Back at the top level asks: exit, change server,
or cancel
- QR scanning works: the page's camera request is mirrored to an Android
permission request (CAMERA, on an https instance - getUserMedia needs a
secure context, as in any browser)
- Downloads work: a DownloadListener hands database backups and other files
to DownloadManager, which puts them in the phone's Downloads folder
- Links to other hosts and non-http schemes open in the system browser, so
a URL in a message no longer navigates the app away from the instance
- Rotating the screen no longer reloads the page
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A thin WebView wrapper that opens a user's own mc-webui instance full
screen, without the browser address bar. It asks once for the server
address and remembers it; all logic stays on the server.
- android/mc-webui-wrapper.apk (1.0, it.wojtaszek.mc.wrapper, minSdk 21)
- docs/android-app.md: download + checksum, "unknown sources" permission,
the Play Protect notice, first connection, and the limitations that come
with a WebView (no notifications, no QR camera, no file downloads)
- README, user guide and whatsnew entries, incl. an app-vs-PWA comparison
- *.apk marked binary so the text=auto rule can never touch it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks dev as ahead of the 2.2.0 release, so tester builds report
2.3.0-dev instead of claiming to be the released version. The final
number is decided when the release is cut - a cycle that turns out to
carry only fixes ships as 2.2.1 instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drop the -dev suffix and date the whatsnew section so release.sh can find
its notes. Also make the build-string example in the whatsnew header read
as an example, instead of naming one particular build that goes stale the
moment it ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Quote button produced `@[user] »text« ` — a shape mc-webui invented back
when it could not send newlines, and one that other MeshCore clients neither
write nor understand. It now writes the quote on its own line behind a '>'
and leaves the cursor underneath it, matching the plain-text convention some
users already type by hand.
processQuotes() styles both syntaxes, so messages sent by older builds keep
their formatting. The '>' match is anchored to a line start or a leading
@[mention] badge and looks for the escaped '>', so neither "5 > 3" in
prose nor the brackets of generated tags can trigger it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Releases panel on GitHub is repository-wide, so browsing dev still
shows the latest stable release in the sidebar - which reads as "dev is
2.1.0" when dev is actually preparing 2.2.0. A note at the top of the
README points at the VERSION file, which is per-branch and therefore
always right.
Deliberately carries no version number of its own: it explains how to
read the two sources rather than duplicating them, so it needs no upkeep
and merges between branches without conflicting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Marks dev as ahead of the 2.1.0 release, so tester builds report
2.2.0-dev instead of claiming to be the released version. The final
number is decided when the release is cut - a cycle that turns out to
carry only fixes ships as 2.1.1 instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushing the tag drags the commit objects to GitHub, so the release page
looks correct while the main branch still points at older code - exactly
what happened on the 2.1.0 release, where origin/main stayed on the
previous merge. The script now fetches origin/main and refuses unless the
local HEAD matches it, naming the push command.
Also spells out what to do when VERSION still carries a -dev suffix,
since the format check alone did not say which way to resolve it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The app already knew exactly which build was running - a calendar version
of commit date plus short hash - but nothing gave a release a name users
could quote, and the repo had no tags at all.
Adds a VERSION file as the single source of truth for a SemVer release
number, read by app/version.py alongside the existing build string rather
than replacing it: the number is for people, the build is for pinning down
a deploy, and both ship in /api/version and the template context. The menu
shows the release first with the build underneath. #versionText still holds
the build string, because the remote-update poller compares it to detect
that the server came back on a new build.
Resolution order is unchanged (frozen file > git > fallback), and a frozen
file written before this change still yields a correct release number, so
an already-deployed server does not need re-freezing to stay sane. The
container has no git, hence COPY VERSION into the image - otherwise a plain
'docker compose build' reports 0.0.0.
scripts/release.sh cuts a release from main: it refuses a dirty tree, a
wrong branch, a malformed number or an existing tag, extracts the notes
from the matching whatsnew section, then tags, pushes and publishes via gh.
Numbering starts at 2.1.0 rather than 1.x: the v2 line has been in
production since March and 'v1' is the archived pre-migration branch, so
1.x would have been ambiguous. Sections in whatsnew before 2.1.0 keep
their date-only headings - they were never tagged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
user-guide: the Map view now opens showing only the selected route, with
the two opt-in checkboxes (All repeaters, Alternative paths) described
alongside how alternatives are coloured and why only their diverging
stretches are drawn. The Filters section gains a paragraph on settings
being remembered per browser, including the deep-link exception.
architecture: added map layer ordering, the segment de-duplication that
keeps alternatives visible, the no-refit re-render on toggle, and the
localStorage filter persistence contract (user-driven writes only, skip
restore on deep link).
whatsnew: three user-facing entries under the pending release section.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>