i18n starter: inventory (script + audit), rows R-553..R-562, language allowlisted in wire-contract gate
gates / gates (push) Successful in 23s
gates / gates (push) Successful in 23s
Phase 0 of the localisation starter: i18n_inventory.py counts every customer-visible Hungarian string; the audit names six further claims in the prompt that live source disproved. Rows for the compare-not-show sites, wizard deletion, the wire-contract comment blind spot, and localisation slices 1-6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# I18N INVENTORY — every Hungarian string a household meets, counted (2026-09-17)
|
||||
|
||||
**Run class:** spike, Phase 0 of the localisation starter. **Operator:** Claude Code on DooPlex.
|
||||
**Baselines:** felhom-controller `89dd3e94b1de` (v0.246.0) · felhom-agent `d9864a94bf62` (v0.132.0) ·
|
||||
felhom.eu `851198af7ff0` (hub v0.117.0) · app-catalog-felhom.eu `94bc5febaca2`.
|
||||
**Instrument:** `scripts/i18n_inventory.py`, sha256
|
||||
`be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c`. Raw output and per-string JSON:
|
||||
`audits/i18n-2026-09-17/inventory.md`, `inventory.json`.
|
||||
**What was counted:** the controller at its baseline commit (a clean worktree of `origin/main`, so the
|
||||
spike's own conversion is NOT in these numbers), the other three repos at their baselines.
|
||||
**Design this feeds:** `architecture/10-localisation.md`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Claims in the starter prompt that live source disproved
|
||||
|
||||
Named first, as the prompt asked. Four were already named in its §1; these are further ones.
|
||||
|
||||
1. **„`fmtTime`, `fmtBytes`, `timeAgo` — none of these exist. The FuncMap has one size helper,
|
||||
`fmtMB`."** — **Wrong, and it is the claim that matters most for localisation.** All three exist
|
||||
(`internal/web/funcmap.go` `timeAgo` L265, `fmtTime` L318, `fmtBytes` L403), and so do **nine more
|
||||
helpers whose output is copy or locale-shaped**: `fmtGB`, `humanBytes`, `timeAgoStr`, `fmtTimeStr`,
|
||||
`fmtTimeShort`, `fmtDuration`, `nextRunLabel` („ma"/„holnap"), `pruneLabel` („vasárnap"/„naponta"),
|
||||
`nextPruneLabel`, `statusText`, and `stateLabel` (the app-state words). There are **four** size
|
||||
helpers (`fmtMB`, `fmtGB`, `fmtBytes`, `humanBytes`), not one. `timeAgo` builds Hungarian with `%d`
|
||||
(„%d perce") — the plural problem lives there.
|
||||
2. **„`fmtMB`, used 10× in templates"** — **8×** (`grep -c` over templates, and the script agrees).
|
||||
3. **„~13 `.Format(` calls in `internal/web/*.go` (`2006-01-02` ×6, `2006-01-02 15:04` ×4, `15:04` ×2,
|
||||
`01-02 15:04` ×1)"** — at the baseline: `2006-01-02` **×4**, `2006-01-02 15:04` **×3**, `15:04` **×2**,
|
||||
`01-02 15:04` ×1 = **10** layout literals, plus **8** `time.RFC3339` (machine, locale-free). The two
|
||||
template layouts are as stated and do disagree (`2006. 01. 02. 15:04` vs `2006-01-02 15:04`).
|
||||
4. **Template line count „1 613 (afternoon) / ~1 838 (morning)"** — **1 553**, by two independent methods
|
||||
(this script, and `grep -cP` on the concatenated templates under `LC_ALL=C.UTF-8`). Neither prompt
|
||||
figure was reproduced. Tried: adding typographic quotes „ ” (1 556), adding dashes and ellipsis
|
||||
– — … (1 751), counting `style.css` too (1 559). The afternoon figure is **+60 (3.9 %)** and its method
|
||||
is unknown; the morning figure is closest to the dash-inclusive variant. **Lines are the wrong unit
|
||||
anyway** — see §1: the string count is 1 867.
|
||||
5. **Go literal count „1 234 in 99 files (afternoon) / ~1 971 in 125 (morning)"** — **1 141 literals in
|
||||
84 files** by a Go lexer (comments skipped), first-boot wizard excluded. A `grep` of quoted Hungarian
|
||||
on non-comment lines gives 1 106 lines in 85 files, so the two methods agree. Counting lines that
|
||||
hold an accented letter **including comments** gives 1 360 in 107 files; including the wizard and
|
||||
`cmd/`, 1 375 in 108. The prompt's figures sit between the comment-free and comment-inclusive
|
||||
counts; **comments explain most of the gap** (Hungarian quoted in Go comments is common here).
|
||||
6. **Console banner „33 Hungarian lines"** — 33 by accented letter, **34** once ASCII-only Hungarian
|
||||
words are counted. Not an error; a method difference, stated.
|
||||
|
||||
Confirmed as the prompt states: 36 page templates + `style.css`; 33 carry Hungarian; the second
|
||||
template set and its one helper (`internal/setup/handlers.go` L79 `timeNow`); 8 wizard pages, **86**
|
||||
Hungarian lines; `customerMessages` in the hub; 53 catalog files with `first_steps` under `app_info`;
|
||||
no i18n library, no `Accept-Language`, no bundle anywhere.
|
||||
|
||||
---
|
||||
|
||||
## 1. Controls — the counts below mean something
|
||||
|
||||
Hungarian is matched by accented letter (the letters written as `\u` escapes, so the script itself is
|
||||
ASCII) **or** by one of 46 ASCII-only Hungarian words („Fut", „Nincs", „Hiba", „Csak", „Megszakadt"…),
|
||||
case-insensitive. Every surface prints a positive control (an ASCII fragment of a Hungarian word known
|
||||
to be there, whose lines the matcher must flag) and a negative control (a fragment known absent; an
|
||||
English sentence that must not be flagged).
|
||||
|
||||
| surface | positive fragment | lines holding it | flagged | negative hits | English line flagged | verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| controller templates | `pult` | 20 | 20 | 0 | no | ok |
|
||||
| controller Go | `ment` | 832 | 278 | 0 | no | ok |
|
||||
| hub customer Go | `ment` | 35 | 18 | 0 | no | ok |
|
||||
| installer banner | `Felhom` | 10 | 5 | 0 | no | ok |
|
||||
| catalog | `Alkalmaz` | 5 | 5 | 0 | no | ok |
|
||||
| guide | `Felhom` | 14 | 13 | 0 | no | ok |
|
||||
|
||||
**Measured limit of the ASCII word list.** The spike's conversion tool first used a 40-word list and
|
||||
**missed eight Hungarian strings on the three pages** („Megszakadt:", „Elavult", „Csak x86",
|
||||
„Pi kompatibilis", „Rendszermonitor", „Most nem", „sikertelen", „ismeretlen"); a by-eye review of what
|
||||
was left found them, and they were added. **An ASCII-only Hungarian word that is not on the list is
|
||||
still invisible to every count here.** The counts below are therefore floors, not totals.
|
||||
|
||||
---
|
||||
|
||||
## 2. The tables
|
||||
|
||||
### 2.1 Controller page templates (`internal/web/templates/`, 36 files)
|
||||
|
||||
**1 867 strings: 1 161 text runs + 88 attribute values + 618 inline-JS string literals.** 1 553 lines
|
||||
carry an accented letter. A *run* is text up to the next block tag or control action — the unit a
|
||||
translator needs.
|
||||
|
||||
| template | HU lines | text | attr | JS |
|
||||
|---|---|---|---|---|
|
||||
| deploy.html | 156 | 87 | 8 | 77 |
|
||||
| storage.html | 110 | 43 | 9 | 98 |
|
||||
| debug.html | 92 | 41 | 16 | 79 |
|
||||
| layout.html (on every page) | 88 | 35 | 1 | 70 |
|
||||
| backups_remote.html | 77 | 87 | 0 | 10 |
|
||||
| settings_system.html | 84 | 58 | 0 | 36 |
|
||||
| backups_apps.html | 80 | 79 | 7 | 6 |
|
||||
| … 26 more | | | | |
|
||||
|
||||
Full per-file table: `audits/i18n-2026-09-17/inventory.md`. **One third of all template copy (618) is
|
||||
inside `<script>`** — modal bodies and confirm questions built as JS strings, often with HTML inside
|
||||
and with the sentence split around a `+ name +` concatenation. Most repeated: „Mentés" 10×, „Jelszó"
|
||||
7×, „← Vissza" 7×, „Törlés", „Hiba", „Beállítás", „Mégsem", „Újraindítás", „Betöltés...", „Frissítés"
|
||||
6× each.
|
||||
|
||||
### 2.2 Controller Go (non-test, wizard excluded)
|
||||
|
||||
**1 141 literals in 84 files.** Classified by the statement they sit in:
|
||||
|
||||
| kind | count | meaning |
|
||||
|---|---|---|
|
||||
| shown | 947 | assigned to page data, flash, alert, event text, struct fields |
|
||||
| error | 184 | `fmt.Errorf` / `errors.New` — **reaches a page** wherever a handler prints `err.Error()` |
|
||||
| compared | 7 | matched, not shown — §3 |
|
||||
| log only | 3 | logs are English by convention; three are not |
|
||||
|
||||
Word-order hazards (non-log): **237** format strings with parameters, **111** concatenations, **42**
|
||||
with a numeric `%d` (English needs a plural form; Hungarian does not inflect after a numeral).
|
||||
Heaviest files: `web/handlers.go` 115, `cloudflare/countries.go` 113 (country names for the geo
|
||||
filter), `web/offbox_handlers.go` 70, `web/storage_handlers.go` 45, `web/handler_debug.go` 39,
|
||||
`backup/offbox_reconstitute.go` 38, `notify/notifier.go` 31. Most repeated: „érvénytelen kérés" 13×,
|
||||
„A távoli mentési cél nincs beállítva." 9×, „Rendszermonitor" 7×.
|
||||
|
||||
**A classification caveat, measured:** a flash message travels to the page **inside the redirect URL**
|
||||
(`?flash=<Hungarian text>`, e.g. the launcher's `ShareFlash`), so it is rendered in whatever language
|
||||
the handler that redirected spoke. Slice 2 has to move these to keys, not only translate them.
|
||||
|
||||
### 2.3 Hub → customer
|
||||
|
||||
**65 literals in 4 files**: `notify/templates.go` 57 (the 39-entry `customerMessages` map, the 4
|
||||
`severityLabels`, the body wrapper, and the claim / re-enroll / reset / claimed / self-bind mails),
|
||||
`notify/dispatcher.go` 4, `web/customer_reset.go` 2, `web/selfbind.go` 2 (plus the public bind page's
|
||||
19 Hungarian lines). 8 are multi-line raw blocks. One message (`app_start_failed`) is in the formal
|
||||
„ön" register. For scale and **out of scope** (operator pages): all hub Go 83 literals; operator
|
||||
templates 64 strings.
|
||||
|
||||
### 2.4 Installer
|
||||
|
||||
Console banner `scripts/iso/felhom-bootstrap.sh`: **34** lines, printed with `printf` blocks to the
|
||||
console device (pairing, bound, `/etc/issue`, no-network screens). The `/etc/issue` text must stay
|
||||
byte-identical to `iso/pkg/debian/postinst` and avoid ő/ű (the console font) — a constraint English
|
||||
does not share but a second language on the same screen does. Download page `website/letoltes.html`:
|
||||
**32** strings.
|
||||
|
||||
### 2.5 Catalog
|
||||
|
||||
**835 strings, ~4 984 words, in all 53 `.felhom.yml`.** No multi-language structure exists.
|
||||
|
||||
| field | strings |
|
||||
|---|---|
|
||||
| `app_info.use_cases[]` | 262 |
|
||||
| `app_info.first_steps[]` | 233 |
|
||||
| `deploy_fields[].description` | 79 |
|
||||
| `deploy_fields[].label` | 68 |
|
||||
| `app_info.prerequisites[]` | 60 |
|
||||
| `description` | 56 |
|
||||
| `app_info.tagline` | 53 |
|
||||
| other (`label`, `help_text`, `default_creds`, `note`, `group`) | 24 |
|
||||
|
||||
The controller parses these with a plain `yaml.Unmarshal` that ignores unknown keys
|
||||
(`internal/stacks/metadata.go` L317), so a sibling key per language is additive.
|
||||
|
||||
### 2.6 Guide
|
||||
|
||||
`runbooks/VOLUNTEER-first-hour.md`: 182 lines, **44 paragraphs (42 Hungarian), ~1 579 words.**
|
||||
|
||||
### 2.7 First-boot wizard — reported separately, never merged
|
||||
|
||||
`internal/setup/templates/` (8 pages): 86 Hungarian lines; **95 strings** (81 text, 12 attr, 2 JS).
|
||||
Go: 16 literals. **When a household can still see it** (read from source): the controller runs
|
||||
`bootstrap.MaybeIngest` (`cmd/controller/main.go` L314) and then `setup.NeedsSetup(cfg)` (L322), which
|
||||
is true when `customer.id` is still empty **or** a `.needs-setup` marker exists in the data dir
|
||||
(`internal/setup/setup.go` L17-25). Ingestion leaves `customer.id` empty — and the wizard therefore
|
||||
starts — when `bootstrap.json` is missing, unreadable, not JSON, not schema `felhom.bootstrap/v2`, or
|
||||
lacks `customer.id` / `hub.url` / `hub.retrieval_password` / the `local_api` block; when the hub pull
|
||||
fails (auth and not-found at once; unreachable after retries at 2/4/8 s); or when the merge, the
|
||||
atomic write or the reload fails (`internal/bootstrap/bootstrap.go` L109-163). **Operator decision
|
||||
2026-09-17: out of scope, obsolete — a separate row to delete it.**
|
||||
|
||||
### 2.8 Dates, numbers, sizes
|
||||
|
||||
| where | layout | sites |
|
||||
|---|---|---|
|
||||
| internal/web Go | `2006-01-02` | 4 |
|
||||
| internal/web Go | `2006-01-02 15:04` | 3 |
|
||||
| internal/web Go | `15:04` | 2 |
|
||||
| internal/web Go | `01-02 15:04` | 1 |
|
||||
| template | `2006. 01. 02. 15:04` (Hungarian) | 1 |
|
||||
| template | `2006-01-02 15:04` (ISO) | 1 |
|
||||
|
||||
Plus 25 `.Format("` calls elsewhere in the controller, and the helpers in §0.1. Sizes print `GB`/`MB`
|
||||
with a **decimal point** (`%.1f`) — Hungarian convention is a decimal comma, so the Hungarian pages are
|
||||
already un-Hungarian here; that is not a localisation regression and the spike does not change it.
|
||||
|
||||
---
|
||||
|
||||
## 3. Compared, not shown — each is a latent bug
|
||||
|
||||
A string matched by its Hungarian wording breaks the moment the wording is translated **or reworded**.
|
||||
|
||||
| where | what is matched | consequence if the text changes |
|
||||
|---|---|---|
|
||||
| `internal/api/router.go:478` | a deploy error contains „kötelező" or „memória" | a validation failure becomes **500 instead of 400** |
|
||||
| `internal/backup/offbox.go:186` | a restic error contains „tárhelykeretet" | the off-site **quota** failure is no longer classified as quota |
|
||||
| `internal/web/alerts.go:208` | a health warning contains „meghajtón" / „adattároló" / „meghajtó" | a disk warning moves from its inline place to the **top banner of every page** |
|
||||
| `internal/web/handlers.go:927` via `offboxStaleWarningMarker` („nincs mentésre jelölt alkalmazás") | a **persisted** warning string | the „selection changed" line is no longer substituted — and a warning written in one language is compared in another |
|
||||
| `internal/web/offbox_handlers.go:132` | — | **false positive** of the detector (`!= ""` belongs to `stageErr`, not the literal) |
|
||||
|
||||
Templates: **0** compares against Hungarian (a detector decoy with `eq .State "Leállítva"` was flagged,
|
||||
so the zero is measured). JS: no `===` / `indexOf` / `includes` against a Hungarian literal.
|
||||
|
||||
---
|
||||
|
||||
## 4. What this inventory does not see
|
||||
|
||||
- ASCII-only Hungarian words outside the 46-word list (§1).
|
||||
- Hungarian arriving at run time from outside the repos: app containers' own UIs, agent error strings
|
||||
relayed verbatim, hub-pushed text.
|
||||
- Whether a `shown` Go literal actually reaches a page (classification is by statement, not by data
|
||||
flow) and whether an `error` literal is printed or swallowed.
|
||||
- The **formal „ön" register** inside Hungarian copy (R-516) — counted only on the converted pages, by
|
||||
the new `i18n_missing_gate.py` (6 occurrences).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,176 @@
|
||||
script sha256: be69b48d4d98ac47c0cec531cae63bee8ef009cacbbe2b5c45725cfb2fd40f0c
|
||||
|
||||
## Controls (ASCII fragment, positive + negative)
|
||||
|
||||
| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |
|
||||
|---|---|---|---|---|---|---|
|
||||
| controller templates | `pult` | 20 | 20 | 0 | False | ok |
|
||||
| controller Go | `ment` | 832 | 278 | 0 | False | ok |
|
||||
| hub customer Go | `ment` | 35 | 18 | 0 | False | ok |
|
||||
| installer banner | `Felhom` | 10 | 5 | 0 | False | ok |
|
||||
| catalog | `Alkalmaz` | 5 | 5 | 0 | False | ok |
|
||||
| guide | `Felhom` | 14 | 13 | 0 | False | ok |
|
||||
|
||||
## Controller page templates
|
||||
|
||||
36 files; 33 carry Hungarian. Lines with an accented letter: **1553**. Strings: **1161 text runs + 88 attribute values + 618 inline-JS literals = 1867**. Template compares against Hungarian: 0. Text runs with a numeric parameter: 7.
|
||||
|
||||
| template | HU lines | text | attr | JS | compare |
|
||||
|---|---|---|---|---|---|
|
||||
| deploy.html | 156 | 91 | 8 | 77 | 0 |
|
||||
| storage.html | 110 | 43 | 9 | 105 | 0 |
|
||||
| debug.html | 92 | 43 | 18 | 84 | 0 |
|
||||
| layout.html | 88 | 38 | 1 | 70 | 0 |
|
||||
| backups_remote.html | 77 | 87 | 0 | 10 | 0 |
|
||||
| settings_system.html | 84 | 58 | 0 | 36 | 0 |
|
||||
| backups_apps.html | 80 | 80 | 7 | 6 | 0 |
|
||||
| backups_restore.html | 76 | 50 | 2 | 30 | 0 |
|
||||
| sharing.html | 68 | 70 | 0 | 8 | 0 |
|
||||
| recovery.html | 52 | 61 | 1 | 5 | 0 |
|
||||
| storage_network.html | 66 | 38 | 6 | 20 | 0 |
|
||||
| backups.html | 47 | 46 | 1 | 13 | 0 |
|
||||
| backups_escrow.html | 56 | 31 | 0 | 29 | 0 |
|
||||
| app_info.html | 45 | 37 | 0 | 20 | 0 |
|
||||
| settings_security.html | 53 | 28 | 4 | 25 | 0 |
|
||||
| monitoring.html | 48 | 43 | 0 | 13 | 0 |
|
||||
| dashboard.html | 35 | 40 | 9 | 0 | 0 |
|
||||
| backups_restore_wizard.html | 40 | 41 | 0 | 6 | 0 |
|
||||
| storage_init.html | 33 | 18 | 1 | 25 | 0 |
|
||||
| app_import.html | 36 | 31 | 1 | 9 | 0 |
|
||||
| stacks.html | 29 | 29 | 7 | 0 | 0 |
|
||||
| settings_notifications.html | 35 | 34 | 1 | 0 | 0 |
|
||||
| storage_attach.html | 21 | 17 | 1 | 12 | 0 |
|
||||
| app_export.html | 24 | 19 | 2 | 8 | 0 |
|
||||
| launcher.html | 25 | 21 | 4 | 3 | 0 |
|
||||
| tier2_config.html | 26 | 26 | 0 | 0 | 0 |
|
||||
| claim.html | 19 | 16 | 3 | 0 | 0 |
|
||||
| logs.html | 8 | 5 | 0 | 4 | 0 |
|
||||
| login.html | 7 | 6 | 1 | 0 | 0 |
|
||||
| launcher_share_password.html | 5 | 4 | 1 | 0 | 0 |
|
||||
| backups_shared.html | 6 | 4 | 0 | 0 | 0 |
|
||||
| catchall.html | 3 | 3 | 0 | 0 | 0 |
|
||||
| launcher_shared.html | 3 | 3 | 0 | 0 | 0 |
|
||||
| app_row.html | 0 | 0 | 0 | 0 | 0 |
|
||||
| icons.html | 0 | 0 | 0 | 0 | 0 |
|
||||
| meta_badge.html | 0 | 0 | 0 | 0 | 0 |
|
||||
|
||||
Most repeated (text + attr):
|
||||
- 10x `Mentés`
|
||||
- 7x `Jelszó`
|
||||
- 7x `← Vissza`
|
||||
- 6x `Törlés`
|
||||
- 6x `Hiba`
|
||||
- 6x `Beállítás`
|
||||
- 6x `Mégsem`
|
||||
- 6x `Újraindítás`
|
||||
- 6x `Betöltés...`
|
||||
- 6x `Frissítés`
|
||||
|
||||
## Controller Go (non-test, first-boot wizard excluded)
|
||||
|
||||
**1141 literals in 84 files.** shown: 947, error (may reach a page): 184, log only: 3, compared: 7. Format strings with parameters (non-log): 237; numeric %d (non-log, plural risk): 42; concatenated (non-log): 111; raw multi-line blocks: 1.
|
||||
|
||||
| file (top 15, non-log) | literals |
|
||||
|---|---|
|
||||
| felhom-controller/controller/internal/web/handlers.go | 115 |
|
||||
| felhom-controller/controller/internal/cloudflare/countries.go | 113 |
|
||||
| felhom-controller/controller/internal/web/offbox_handlers.go | 70 |
|
||||
| felhom-controller/controller/internal/web/storage_handlers.go | 45 |
|
||||
| felhom-controller/controller/internal/web/handler_debug.go | 39 |
|
||||
| felhom-controller/controller/internal/backup/offbox_reconstitute.go | 38 |
|
||||
| felhom-controller/controller/internal/notify/notifier.go | 31 |
|
||||
| felhom-controller/controller/internal/backup/update_guard.go | 28 |
|
||||
| felhom-controller/controller/internal/stacks/migrate.go | 28 |
|
||||
| felhom-controller/controller/internal/stacks/update.go | 25 |
|
||||
| felhom-controller/controller/internal/appexport/restore.go | 23 |
|
||||
| felhom-controller/controller/internal/web/alerts.go | 22 |
|
||||
| felhom-controller/controller/internal/web/share_handlers.go | 22 |
|
||||
| felhom-controller/controller/internal/appexport/export.go | 21 |
|
||||
| felhom-controller/controller/internal/api/router.go | 20 |
|
||||
|
||||
Most repeated (non-log):
|
||||
- 13x `érvénytelen kérés`
|
||||
- 9x `A távoli mentési cél nincs beállítva.`
|
||||
- 7x `Rendszermonitor`
|
||||
- 6x `Nem bekötött`
|
||||
- 6x `érvénytelen csatlakoztatási pont`
|
||||
- 5x `Beállítások`
|
||||
- 5x `Hiányzó alkalmazás.`
|
||||
- 4x `Belső hiba a jelszó mentésekor`
|
||||
- 4x `Az ügynök nem elérhető.`
|
||||
- 3x `egy másik mentési/visszaállítási művelet már fut`
|
||||
|
||||
### Compared, not shown (controller Go)
|
||||
|
||||
- felhom-controller/controller/internal/api/router.go:478 `kötelező`
|
||||
- felhom-controller/controller/internal/api/router.go:478 `memória`
|
||||
- felhom-controller/controller/internal/backup/offbox.go:186 `tárhelykeretet`
|
||||
- felhom-controller/controller/internal/web/alerts.go:208 `meghajtón`
|
||||
- felhom-controller/controller/internal/web/alerts.go:208 `adattároló`
|
||||
- felhom-controller/controller/internal/web/alerts.go:208 `meghajtó`
|
||||
- felhom-controller/controller/internal/web/offbox_handlers.go:132 `A távoli mentési cél elmentve.`
|
||||
- felhom-controller/controller/internal/web/handlers.go:927 via `offboxStaleWarningMarker` (defined felhom-controller/controller/internal/web/handlers.go) `nincs mentésre jelölt alkalmazás`
|
||||
|
||||
### Compared, not shown (templates)
|
||||
|
||||
|
||||
## Hub customer surfaces (notify, self-bind, reset, claim)
|
||||
|
||||
**65 literals in 4 files.** shown: 65, error (may reach a page): 0, log only: 0, compared: 0. Format strings with parameters (non-log): 10; numeric %d (non-log, plural risk): 0; concatenated (non-log): 0; raw multi-line blocks: 8.
|
||||
|
||||
| file (top 15, non-log) | literals |
|
||||
|---|---|
|
||||
| felhom.eu/hub/internal/notify/templates.go | 57 |
|
||||
| felhom.eu/hub/internal/notify/dispatcher.go | 4 |
|
||||
| felhom.eu/hub/internal/web/customer_reset.go | 2 |
|
||||
| felhom.eu/hub/internal/web/selfbind.go | 2 |
|
||||
|
||||
Most repeated (non-log):
|
||||
- 2x `Teszt értesítés`
|
||||
|
||||
For scale, NOT in scope: all hub Go non-test = 83 literals (shown 83, error 0, log 0, compare 0); hub operator templates = 64 strings, 52 HU lines.
|
||||
|
||||
## Installer
|
||||
|
||||
Console banner `felhom.eu/scripts/iso/felhom-bootstrap.sh`: **34** Hungarian lines.
|
||||
Download page `felhom.eu/website/letoltes.html`: 24 text + 6 attr + 2 JS = **32** strings, 27 HU lines.
|
||||
|
||||
## Catalog
|
||||
|
||||
53 `.felhom.yml`; 53 carry Hungarian; **835 strings, ~4984 words**.
|
||||
|
||||
| field | strings |
|
||||
|---|---|
|
||||
| app_info.use_cases[] | 262 |
|
||||
| app_info.first_steps[] | 233 |
|
||||
| deploy_fields[].description | 79 |
|
||||
| deploy_fields[].label | 68 |
|
||||
| app_info.prerequisites[] | 60 |
|
||||
| description | 56 |
|
||||
| app_info.tagline | 53 |
|
||||
| label | 11 |
|
||||
| help_text | 6 |
|
||||
| app_info.default_creds | 5 |
|
||||
| note | 1 |
|
||||
| group | 1 |
|
||||
|
||||
## Guide
|
||||
|
||||
`felhom.eu/documentation/runbooks/VOLUNTEER-first-hour.md`: 44 paragraphs (42 Hungarian), ~1579 words.
|
||||
|
||||
## First-boot wizard (reported separately -- never merged into the dashboard count)
|
||||
|
||||
8 templates: 86 HU lines; 81 text + 12 attr + 2 JS = **95** strings. Go: 16 literals (shown 16, error 0, log 0, compare 0).
|
||||
|
||||
## Dates and sizes
|
||||
|
||||
| where | layout | sites |
|
||||
|---|---|---|
|
||||
| internal/web Go | `2006-01-02` | 4 |
|
||||
| internal/web Go | `2006-01-02 15:04` | 3 |
|
||||
| internal/web Go | `15:04` | 2 |
|
||||
| internal/web Go | `01-02 15:04` | 1 |
|
||||
| template | `2006. 01. 02. 15:04` | 1 |
|
||||
| template | `2006-01-02 15:04` | 1 |
|
||||
|
||||
`fmtMB` sites: 8 in templates, 1 in Go. `.Format("` calls in the controller outside internal/web: 25.
|
||||
@@ -711,7 +711,7 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
|
||||
| **R-508** | **[P2-MEDIUM] Customer `tester-1` has no registered e-mail, so neither the self-bind link nor the setup code can reach a volunteer.** MEASURED 2026-09-14: the edit form's `email` value is empty; on bind the hub logged `[ERROR] [claim] claim code generated (gen 1) but customer tester-1 has NO registered email — deliver via resend after setting one`. A volunteer onboarded on this record would sit at „A szerver beállítása" with no code. **What it needs:** the operator sets the volunteer's address on the record before sending the guide (day-0 A.2). The hub's customer page could warn when a record with an unclaimed box has no e-mail — the log line exists, the page says nothing. | **WAITING-ON-OPERATOR — rank P2-MEDIUM; owner: operator (record), CC (page warning)** |
|
||||
| **R-509** | **[P1-HIGH] A box installed for an EXISTING customer never gets the self-bind e-mail the console tells the volunteer to open.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1): customer `tester-1` now has `tester1@felhom.eu` registered; the box registered as appliance 28 at 17:44:53Z and its console says „Nyisd meg az e-mailben kapott linket"; **ten minutes later the mailbox (read through the Gmail connector) held 0 messages to that address.** Cause, from source: the hub auto-sends the link only at customer creation (`hub/internal/web/configs.go:725`) and at RESET completion (`customer_reset.go:162`); a customer whose e-mail was added later, or whose previous box was destroyed, never receives one unless the operator presses „Send self-bind link". The volunteer guide's operator prerequisites do not list that press. Intervention **I1** of the big night (the operator's button pressed). **Fix shape (for the operator to choose):** send the link when an unclaimed appliance registers and a customer with no host is waiting, or add the press to the guide's operator prerequisites (day-0 A.2). **SHIPPED hub v0.114.0 (2026-09-15), NOT YET PROVEN BY A REAL MAIL:** triggers added — e-mail set/changed on a customer with no box, and host delete — each re-checking no bound host; every send recorded as `selfbind_link_sent` and shown on the Setup tab. Unit-proven with a red-proof (`TestSelfBind_EmailSetOnWaitingCustomerSendsLink`). The live check with the Gmail-read mailbox was NOT run: it needs a throwaway customer, and deleting one runs the RESET cascade (ep0 `deprovision` + Cloudflare), which is fenced without the operator's word. **Closes on:** one real mail from either trigger. | **READY — rank P1-HIGH; owner: CC (hub fix) · operator (which fix shape)** |
|
||||
| **R-511** | **[P2-MEDIUM] A customer whose box is rebuilt keeps its ep0 PBS token, and then the DR tier can be neither provisioned nor re-issued: the hub's error advises the one action that refuses.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, `tester-1`, DR tier ticked): on the new box's WireGuard registration the hub logged `[ERROR] pbsdr auto-provision for tester-1 (WG-registration hook): the endpoint already holds a PBS token for tester-1 but the hub has no descriptor — use the explicit "Re-issue PBS credentials" action — save the customer config to retry`. The operator's `POST /configs/tester-1/pbsdr-reissue` → **400 `No provisioned PBS DR tier for this customer`** (`hub/internal/web/pbsdr.go` ~411). The token was left by the doorstep walk's host delete (a host delete does not deprovision tenancy; only RESET does, which also removes the tunnel). So a box rebuilt for an existing customer — the reinstall journey — has no whole-guest off-site tier and no button that restores it. **Fix shape:** let re-issue adopt an existing endpoint token when the descriptor is absent (the message already assumes it does), or have host delete offer to drop the PBS token. **SHIPPED hub v0.114.0 (2026-09-15):** re-issue ADOPTS the endpoint token when the descriptor is absent and the DR flag is on (re-key + descriptor rebuilt from the endpoint + `pbsdr_adopted` audit row); DR flag off still refuses, endpoint untouched (both pinned, red-proofed). **NOT proven on tester-1's real state:** re-issue needs an enrolled host and tester-1 has none. **ep0 cleanup DONE on the operator's yes (2026-09-15 08:33Z):** the one doorstep snapshot `ns tester-1 / ct/9201/2026-09-14T16:04:53Z` (logical 1 928 820 672 B) forgotten via the local PBS API; namespace lists empty; token `felhom@pbs!tester-1` kept; nothing outside tester-1 read or touched (`D3-*` in `audits/evidence-p1fixes-2026-09-15/`). The token-only release on host delete was NOT built → R-526. **Closes on:** adopt ending in a descriptor the next tester-1 box consumes **CONSEQUENCE MEASURED 2026-09-16:** the code fix shipped for this row is sound and currently INERT - the adopt path runs and the ENDPOINT refuses it for a missing `Datastore.Modify` grant (**R-534**). On the drill box the result is that a fresh install has NO off-site tier at all, and read-only listing of ep0 shows `ns/tester-1/ct` empty before and after the whole drill. This row stays open until R-534's grant is given and a re-issue is seen to succeed. **CLOSED 2026-09-16 — the adopt path is PROVEN LIVE.** The rebuilt-customer case reproduced by itself on a fresh box (the WG-registration hook refused exactly as this row describes and named the Re-issue action), and the adopt then completed: reissue ok → pbsdr ADOPTED (gen 2) → the box consumed the single-use secret two seconds later. The code shipped 2026-09-15 was sound; what made it inert was the ep0 grant (R-534), now given. | **CLOSED 2026-09-16 — proven live on a fresh box** |
|
||||
| **R-516** | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. | **READY — rank P3-LOW; owner: CC (controller + catalog)** |
|
||||
| **R-516** | **[P3-LOW] English a customer meets on a fresh box and its apps' first screens — enumerated by the big night.** MEASURED 2026-09-14 (BIGNIGHT, VM 333, ISO 1.27.1, controller 0.242.0). Felhom-owned: (1) the dashboard menu item **„Debug"**; (2) the dashboard CPU tile **„Load: 0.29 / 0.39 / 0.37"**; (3) the launcher tile **„Filebrowser"** opens a login in English with no Felhom text (R-513); (4) the storage page mixes formal „Adjon hozzá / Csatlakoztasson" with the product's „te". App first screens a household meets before any Felhom text helps: (5) **Uptime Kuma 2.4 opens on „Which database would you like to use?"** (SQLite / Embedded MariaDB, „Next") — the app card's „Első lépések" does not mention it; (6) PrivateBin, Gokapi, AdventureLog and FileBrowser UIs are English (the apps' own). Already rows: the Proxmox installer screens (R-495, answered by the guide), `wiki.DOMAIN` (R-498). **Fix shape:** rename „Debug"/„Load" (controller); add the Uptime Kuma database step to its card, or pre-seed `db-config.json` for SQLite in the template (catalog). **Added by F4 (20:04:54Z):** (7) the storage page prints the disconnect time as a raw ISO UTC string „Leválasztva: 2026-09-14T19:58:02Z"; (8) the „Meghajtó leválasztva" banner appears twice on every page; (9) „4 telepített alkalmazás nem fut — nézze meg a rendszermonitort" uses the formal form. **Added by F7 (20:50–21:00Z, system disk at 95 %):** (10) a banner on every page in English, „**SSD disk usage high: 90%**"; (11) the dashboard tile reads „Rendszer (/) 61.8 GB / 68.7 GB (**90%**)" while `df` reports **95 %** (reserved blocks ignored), and „(/)" labels the data volume `/mnt/sys_drive`; the deploy page says nothing about free disk. **Added by the i18n spike (2026-09-17, controller v0.247.0):** (12) **six formal („ön") forms in the converted dashboard copy** — „Olvassa be telefonnal" (launcher QR hint), „Biztosan kikapcsolja a megosztást?" (launcher), „Ha újratelepíti … importálnia kell" (the layout's remove-app modal), „Kérjük, vegye fel a kapcsolatot" (backups empty state). They were NOT fixed — a localisation release may not change Hungarian bytes — and are now COUNTED by `controller/scripts/i18n_missing_gate.py` (`HU_FORMAL_CEILING` = 6, a ratchet: a new one convicts, fixing one lowers it). The inventory (`audits/I18N-INVENTORY-2026-09-17.md`) is the list this row closes against in localisation slice 6 (R-561). | **READY — rank P3-LOW; owner: CC (controller + catalog)** |
|
||||
| **R-518** | **[P2-MEDIUM] „Mentés most" on the whole-system backup stops every app for about eight minutes while the page promises „csak néhány másodpercre".** MEASURED 2026-09-14 (BIGNIGHT, VM 333, 12 apps): the button's call quiesced all 12 stacks at 19:03:23Z (first stopped 19:03:27Z); the local vzdump ran 19:03:49 → 19:09:59Z; the controller then kept the apps stopped for the second (PBS) tier and restarted them at 19:10:09Z after it failed, the last started 19:11:12Z (`phase4/guest-backup-quiesce-log.txt`) — **≈ 7 m 45 s** with every app answering 404. The page under the button: „Pillanatkép-mód: az alkalmazások csak néhány másodpercre állnak le." A household pressing it at dinner loses every app for the length of the dump, and longer on a bigger box. **Fix shape:** state the real expected downtime (it scales with data), or quiesce per tier and not across a second tier's attempt; do not start a tier whose storage is absent (see R-517). **NARROWED 2026-09-15 (controller v0.243.0 + agent v0.131.0):** a tier whose storage the agent reports absent is skipped before anything stops (`backup_tier_skipped`, once per absence; unknown never skipped), and the button copy now says „általában néhány perc, nagyobb adatnál több". Unit-proven with red-proofs. **Still open:** quiesce per tier, so a slow second tier does not keep every app down. | **READY — rank P2-MEDIUM; owner: CC (controller)** |
|
||||
| **R-519** | **[P2-MEDIUM] After a backup torn by a power cut, an app's restore point carries the new database dump's time while its files are from the previous run — and no customer screen says the run was interrupted.** MEASURED 2026-09-14 (BIGNIGHT F2, VM 333): „Mentés most" 19:40:03Z; the power was cut 19:40:08Z while adventurelog was stopped for its volume dump. On disk afterwards, `backups/primary/adventurelog`: `db-dumps/adventurelog-postgres.sql` **19:40:07**, `volume-dumps/*.tar` **19:00:35**, `manifest.json created_at 19:02:34Z`; bookstack the same shape (sql 19:40:07, tars 19:00:48). `GET /api/backup/snapshots` for both → `time 2026-09-14T19:40:07Z, helyi` (`phase5/F2/units-on-disk.txt`, `backup-honesty.txt`). `/backups/apps` shows „Utolsó adatbázis mentés 2026-09-14 21:40 · … OK" and every app „Utolsó: 5 perce"; `/backups` and `/dashboard` contain no word of an interruption (fragments `megszakad|sikertelen|nem sikerült` = 0; control „hiba" appears in the standing warning text). The controller itself knew: `[appstop] crash recovery: an app-data backup (volume dump) … was interrupted … restarting them: [adventurelog]` and pushed `backup_failed (error)` to the hub. A household restoring „the 21:40 backup" gets 21:00 files for BookStack's uploads. **Fix shape:** date a point by the oldest part it contains (or mark it partial) and show the interrupted run on the backups page until the next complete one. **F6 (drive unplugged 1 s into a backup, 20:37:59Z) adds three facts:** the run skipped four apps' volume dumps („Skipping volume dump for immich — drive disconnected", also jellyfin, nextcloud, paperless-ngx) and still reported `db_dump {"count":6, "success":true}`; nextcloud's point is dated 20:37:59Z (its SQL finished before the unplug) beside 19:01 volume tars; and a torn `immich-postgres.sql.tmp` (20:38:00) plus F3's `pre-restore-…-nextcloud-mariadb.sql.tmp` are left in the units on the drive. Immich's point correctly stayed at 19:02:34Z (the `.tmp` was not promoted). `/backups/apps` fragments `kihagy|sikertelen|részleges` = 0 (`phase5/F6/`). | **READY — rank P2-MEDIUM; owner: CC (controller)** |
|
||||
| **R-520** | **[P3-LOW] A power cut during a guarded Update leaves no record that an update was running, so whether the journal resumes or aborts honestly is unmeasured for a real version change.** MEASURED 2026-09-14 (BIGNIGHT F3, VM 333): the only Update the catalog allowed after the Phase 4 revert was a same-version one on nextcloud; power was cut 2 s after `update nextcloud: phase pulling` (safety dump written, pin advanced to the unchanged definition). After boot: no log line resumes, aborts or names the interrupted update; `app.yaml` `pinned_images` = `installed_images`, no hold, no verdict; page „Fut · Naprakész". Nothing wrong was produced — and nothing could have been, with identical images. **What it needs:** the same cut on a real bump (a throwaway app with a one-step catalog move on a scratch branch or the scratch guest), asserting the page's message and the pin after boot. | **READY — rank P3-LOW; owner: CC (drill)** |
|
||||
@@ -731,6 +731,16 @@ class (an image `VOLUME` at an unmounted path) is still live — `immich-server`
|
||||
| **R-548** | **[P3-LOW] The whole-guest backup’s LOCAL tier cannot fit on a small-system-disk box, and will retry on that tier for ever.** MEASURED 2026-09-17 (chaos night) on `tester-1-022354`: a whole-guest backup wrote a **~29 GB source** (`mp0` = `local-lvm:vm-9201-disk-1`, 70 G provisioned, 40.58 % used, `backup=1`) into `pve-root`, which on a 32 GB system disk is **14 GB total with ~4.9 GB free**. Two samples thirty seconds apart showed the archive growing ~497 MB while free space fell ~475 MB — **~16 MB/s, i.e. under four minutes to a full `/`** on the nested PVE. **The product’s behaviour is correct and legible throughout:** it failed the tier and said which one — `whole_guest_backup_failed` (error, operator-only): „Whole-guest backup FAILED on the local tier — retrying with backoff (next attempt in 15m0s)” — its status surface agreed (`target_id:"local"`, `success:false`, `size_bytes:0`), and the **off-site tier then ran from the same snapshot and succeeded in ~8½ minutes, encrypted to ep0, consuming no local disk at all**. So the data still left the house. What is filed is the loop: on a box shaped like this the local tier can **never** succeed, and it keeps retrying on a backoff for ever, burning I/O and risking `/` each time. **Honest caveat:** the 32 GB system disk is this drill’s fixture choice, so the row is conditional on disk size — but nothing in the product checks whether the local target could ever hold the source before trying. **Fix shape:** compare the source size against the target’s free space before starting the local tier and skip it with a clear reason, rather than discovering it at ~16 MB/s. Evidence: `audits/evidence-chaos-night-2026-09-17/round-6.txt`. | **READY — rank P3-LOW; owner: CC** |
|
||||
| **R-551** | **[P3-LOW] No Tier-0 box can put the escrow ceremony in the state R-546 fixes — paused AND connected to its agent — so the readiness branches are proven only by tests.** FOUND 2026-09-17 while live-validating controller v0.246.0 (R-546). The branches (reminder bar held back while the agent's preflight is not `ok`; the waiting card on `/backup/escrow`; `POST /api/escrow/start` refused 409 before staging) need a box whose off-site tier is configured, whose escrow is NOT done, and whose controller reaches the agent. Measured on demo-hp: **9201** reaches the agent but is escrowed (the bar is off by design there; making it paused would be hand-set state on the standing demo box, and a real ceremony would supersede its live escrow — the hub keeps ONE `host_escrow` row per host, `host_id PRIMARY KEY`); **9202** is paused-capable but has **no local-API token at all** (its `bootstrap.json` holds only `schema`, `customer.id`, `disposition`), so its readiness is always UNKNOWN and the bar always shows. The fresh-bind window where this state occurs naturally (~17 min, chaos night Phase 0) needs a fresh install. **What IS proven:** five tests driving the real pages and handler through `ServeHTTP` with a fake agent, each red-proofed; and chaos night measured live that the agent's preflight is red for ~17 minutes after a bind and turns green by itself (`evidence-chaos-night-2026-09-17/phase0-escrow-*`). **Fix shape:** give the scratch guest a local-API token through the agent's own provisioning path, or walk R-546 on the next fresh install. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-552** | **[P3-LOW] An interrupted-restore notice for an app that is then REMOVED stays on the restore page for ever.** FOUND 2026-09-17 by CC reviewing its own controller v0.246.0 (R-550) during live validation. The per-app notice (`Manager.opInterrupted`, persisted in `restore-status.json`) is cleared in exactly one place — `BeginRestoreOp` for that app (`internal/backup/opstatus.go`) — and `removeStack` (`internal/api/router.go`) never touches the restore record. So a household that answers „A visszaállítás megszakadt … indítsd el újra" by REMOVING the app instead of restoring it keeps a „Megszakadt visszaállítás" card about an app that no longer exists. Measured shape, not hypothetical: on 9201 the notice cleared only when homebox was restored again (08:54:50Z, card count 0) — the teardown deliberately took that path before removing it. **Fix shape:** `removeStack` clears the app's notice (a `ClearInterruptedRestore(stack)` beside the existing update-hold clear, R-491's precedent), with a wiring test. Not fixed in v0.246.0: found after the release was built; one release per repo per session. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-553** | **[P3-LOW] Four places decide BEHAVIOUR by matching Hungarian wording — each breaks the day the words change, in either language.** FOUND 2026-09-17 by the i18n inventory (`audits/I18N-INVENTORY-2026-09-17.md` §3), read at source: (1) `controller/internal/api/router.go:478` picks **400 vs 500** for a deploy error by `strings.Contains(err, "kötelező"/"memória")`; (2) `internal/backup/offbox.go:186` classifies the off-site **quota** failure by „tárhelykeretet" in a lower-cased error; (3) `internal/web/alerts.go:208` moves a disk warning inline-vs-top-banner by „meghajtón"/„adattároló"/„meghajtó"; (4) `internal/web/handlers.go:927` compares a **persisted** off-site warning against `offboxStaleWarningMarker` („nincs mentésre jelölt alkalmazás") — a warning written in one language is compared in another. (A fifth hit, `offbox_handlers.go:132`, is a detector false positive.) Templates: zero (detector decoy-tested). **Fix shape:** a typed error / sentinel / reason code at each producer, the display text derived from it, one red-proofed test per site asserting the consequence (status code, quota class, placement, substitution) with the wording changed. **Blocks localisation slice 2 (R-557)** — it goes first. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-554** | **[P3-LOW] Delete the first-boot setup wizard — obsolete by design, still reachable.** OPERATOR DECISION 2026-09-17 (localisation starter, decision 4: „out of scope, obsolete"). `02-controller-module-map.md` L56 calls `internal/setup/` obsolete; `cmd/controller/main.go` L322 still enters it when `setup.NeedsSetup(cfg)` — `customer.id` empty after bootstrap ingestion, or a `.needs-setup` marker (`internal/setup/setup.go` L17-25). Ingestion leaves `customer.id` empty on a missing/invalid `bootstrap.json`, a failed hub pull, or a failed merge/write/reload (`internal/bootstrap/bootstrap.go` L109-163) — so a box whose first boot cannot reach the hub shows a household an 8-page wizard (95 Hungarian strings, its own template set and CSRF). **Fix shape:** decide what such a box shows instead (a single „cannot reach Felhom yet, retrying" page — needs no decision beyond copy), then delete `internal/setup/` and `runSetupMode`; red-proof that a failed ingestion renders the waiting page, not a 404. **Check first** whether any drill/golden path still relies on `.needs-setup`. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-555** | **[P3-LOW] The wire-contract gate counts a field as received when its name appears in a Go COMMENT on the receiving side.** FOUND 2026-09-17 by CC adding the report's `language` field (controller v0.247.0): `scripts/wire_contract_gate.py` passed WITHOUT an allowlist entry, because `receiver_tokens()` tokenises whole files and the word „language" occurs hub-side only in a comment (`hub/internal/web/configs.go:558`, „this page's existing language"). The shape is the gate's own named failure class (name-for-fact, R-421): any English tag name that also appears in hub prose passes unread. The field was allowlisted by hand with this row named. **Fix shape:** strip `//` and `/* */` comments (and template `{{/* */}}`) before tokenising; add the decoy „a tag whose name appears only in a receiver comment must convict"; expect a handful of currently-passing tags to surface — each is a finding, not noise. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-556** | **[P3-LOW] Localisation slice 1 — the other 31 dashboard templates in English, Hungarian byte-identical.** PLAN 2026-09-17 (`architecture/10-localisation.md` §10). ~1 500 strings, ~600 of them inside `<script>`. Per page: capture parity fixtures from the UNCONVERTED template, run `controller/scripts/i18n_extract.py`, **review by eye what it left** (its ASCII-only word list missed 8 strings on the first three pages), translate, parity + English page tests green. Also: show the language switch to every household (§3, decision 6); give the retrieval-promise gate English stems (it scans Hungarian only); widen the English page test past its fixture-string masking (§2.3). Cost 12–16 CC-hours over three releases. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-557** | **[P3-LOW] Localisation slice 2 — Go-side customer strings follow the language.** PLAN 2026-09-17 (10 §10). Inventory §2.2: 947 shown + 184 error literals in 84 files; 237 format strings, 111 concatenations, 42 numeric `%d` (English plurals). Flash messages travel inside the redirect URL (`?flash=<Hungarian>`) and must become keys; `cloudflare/countries.go` (113 country names); alert texts; handler errors printed with `err.Error()`. **After R-553** (the four compare-not-show sites). Cost 16–20 CC-hours. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-558** | **[P3-LOW] Localisation slice 3 — the hub's customer e-mails follow the household's language; the operator sets it at customer creation.** PLAN 2026-09-17 (10 §3, §10), operator decision 2. The box already reports `"language"` (controller v0.247.0); nothing hub-side reads it. Build: a per-customer language on the hub (default hu) set at creation and rendered into `controller.yaml` beside `customer.*` (`hub/internal/configgen/configgen.go`), used by the box only while `settings.json` has no choice; the dispatcher picks the language the box REPORTS; English for the 39 `customerMessages`, the 4 severity labels, the body wrapper, the claim/re-enroll/reset/claimed/self-bind mails and the public bind page (inventory §2.3). Delete the `language` allowlist entry in `wire_contract_gate.py` when the field is read. Cost 6–8 CC-hours, one hub + one controller release. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-559** | **[P3-LOW] Localisation slice 4 — the console banner and the download page in English. SCOPE UNCONFIRMED.** PLAN 2026-09-17 (10 §10, open decision 1b): the starter listed them; the operator's scope ruling names „the controller, emails, guide, app catalog" and not them. Inventory §2.4: 34 banner lines (`scripts/iso/felhom-bootstrap.sh`, printf to the console; `/etc/issue` byte-coupled to `iso/pkg/debian/postinst`, console font avoids ő/ű) and 32 strings on `website/letoltes.html`. Cost 4–6 CC-hours plus an ISO release train. | **BLOCKED on operator (decision 1b); owner: CC** |
|
||||
| **R-560** | **[P3-LOW] Localisation slice 5 — catalog cards, settings and first steps in English.** PLAN 2026-09-17 (10 §7, §10). Inventory §2.5: 835 strings, ~4 984 words across all 53 `.felhom.yml` (use_cases 262, first_steps 233, deploy_fields descriptions 79 / labels 68, prerequisites 60, description 56, tagline 53). Proposed format: an `i18n: {en: {...}}` block inside each `.felhom.yml` (controller's `yaml.Unmarshal` ignores unknown keys, so older controllers are unaffected), field-by-field fallback. Needs a controller change to read it and catalog copy gates with an English rule. Cost 12–16 CC-hours. | **READY - rank P3-LOW; owner: CC (catalog + controller)** |
|
||||
| **R-561** | **[P3-LOW] Localisation slice 6 — the volunteer guide in English, then a stranger's first hour in English; closes R-516.** PLAN 2026-09-17 (10 §10). `runbooks/VOLUNTEER-first-hour.md`: 44 paragraphs, ~1 579 words. Then the 2026-09-14 walk repeated by an English speaker on a fresh box, R-516 closed against `audits/I18N-INVENTORY-2026-09-17.md`. Depends on R-556..R-560. Cost 6–8 CC-hours plus the attended walk. | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-562** | **[P3-LOW] Dates and sizes are not formatted for any locale — and the Hungarian pages disagree with themselves.** FOUND 2026-09-17 by the i18n inventory §2.8: the two template date layouts differ (`2006. 01. 02. 15:04` Hungarian vs `2006-01-02 15:04` ISO); 10 layout literals in `internal/web` Go and 25 elsewhere pick formats ad hoc; sizes print a decimal POINT (`%.1f GB`, 4 helpers) where Hungarian uses a comma; `timeAgo`/`nextRunLabel`/`pruneLabel` produce Hungarian words outside the three converted pages. Not changed by v0.247.0 (Hungarian bytes are frozen by the parity rule). **Fix shape:** one date and one size formatter per language in `internal/i18n`, the Hungarian output deliberately changed in ONE reviewed release with the parity fixtures re-captured for that release only and the change named in its CHANGELOG. Needs an operator word on the Hungarian format (comma, date style). | **READY - rank P3-LOW; owner: CC** |
|
||||
| **R-537** | **[P1-HIGH] The app-backup page labels the tier-1 backup „DB + Konfig + Adatok" and prints the app's data-drive size next to it — but the tier-1 unit contains NO drive-side app data at all.** MEASURED 2026-09-16 on the drill box (fresh install, controller 0.243.0, one drive, tier 2 and tier 3 both „Nincs beállítva"): five photos (3 000 000 B) were uploaded into Nextcloud through its own WebDAV interface, then the customer-visible „Mentés most" was pressed (`POST /api/backup/run` → 200, the unit grew 25 337 B → 978 MB). The resulting unit's `manifest.json` lists `db-dumps` + three **docker volume** dumps and nothing else; listing the 781 MB `nextcloud_nextcloud_html.tar` (29 346 entries, positive control `version.php` = 3 hits) gives **`Fotok` = 0 and `nyaralas` = 0**, and `./data/` is the empty bind-mount point. A `find` over the whole `backups/` tree for `*appdata*` / `*Fotok*` returns nothing. The page nevertheless renders „1. mentés … DB + Konfig + Adatok" and „Nextcloud Adatlemez 65.1 MB" — a size measured on exactly the data it does not copy (`internal/web/handlers.go:1176-1178`, `BackupContents`). **This is a truth defect, not a design defect:** `07-backup-architecture.md` §6.2 places nextcloud's file leg at **Tier 2 and Tier 3 only**, and its „[FACT] What the whole-guest tiers do NOT carry" says `mp8 /mnt/felhom-drives` is out of vzdump scope (confirmed live: „excluding bind mount point mp8 … (not a volume)"). So on a one-drive box with no off-site tier — the state every fresh install starts in — the household's files are in **no backup**, while the page says „Adatok". Same family as R-517/R-518. **Fix shape:** render tier-1 contents from the capture set actually written (`ComputeCaptureSet`), so a unit with no file leg reads „DB + Konfig" and the drive size is not shown beside it; and say on the page that the app's files need tier 2 or tier 3. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** The contents label is computed PER TIER from what that tier captures: Tier 1 says „Adatok" only when the app's data really is in the volumes the unit captured, and a class-A app carries one sentence saying where its files ARE protected. Proven on demo-hp through the page the customer opens: Paperless-ngx reads „1. mentés … DB + Konfig" with „Az alkalmazás fájljait a távoli másolat (és a második meghajtó) védi …", while its „2. mentés" row still reads „DB + Konfig + Adatok". Red-proof: restoring the old app-shaped label fails `TestAppBackupRows_Tier1LabelDoesNotClaimFilesItCannotHold`. **RE-PROVEN 2026-09-16 on a FRESH box** (installed from the built ISO 1.28.0, controller 0.244.0, off-site on by default): the Nextcloud row read „1. mentés … DB + Konfig" with the new sentence, „2. mentés … Nincs 2. (off-drive) másolat", „3. mentés Sikeres restic → …your-storagebox.de"; „DB + Konfig + Adatok" appeared ZERO times while the local unit held no file leg. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
|
||||
| **R-538** | **[P1-HIGH] A tier-1 app restore reports plain success and leaves Nextcloud listing files whose bytes were never in the backup — and it destroys the app's own trash, the customer's last copy.** MEASURED 2026-09-16 on the drill box, F10 („a child deletes the photo folder"): the five photos were deleted through Nextcloud (DELETE 204, PROPFIND 404), then restored through the page exactly as a customer would (`POST /backup/restore` `stack_name=nextcloud` `snapshot_id=helyi` → 302, finished in **35 s**, „A(z) nextcloud: 3 adatkötet és az adatbázis visszaállítva — az alkalmazás újraindult."). Afterwards the folder is back and **lists all five photos**, and **none of them opens**: `GET nyaralas-1..5` = 404 / 503×4 with `Sabre\DAV\Exception\NotFound`, while the positive controls at the same moment pass (`status.php` 200, WebDAV PUT 201, GET 200). Cause: the replayed MariaDB dump (11:01:45Z) knows the photos, the bytes live on `mp8` and were never captured (R-537). **Worse:** the bytes were still on the drive in Nextcloud's own trash (`appdata/nextcloud/admin/files_trashbin/files/Fotok.d1789556707/nyaralas-1..5.jpg`, all five present) and the restored database no longer references them — the trash listing comes back **empty**, so „restore from trash", the one route that would have worked, is gone. The customer is left with five unopenable photos, a success message, and no warning. **Fix shape:** before replaying a database whose app has an uncaptured file leg, refuse or warn („ennek az alkalmazásnak a fájljai nincsenek ebben a mentésben — a visszaállítás után a fájlok hiányozni fognak"); and never present a DB-only restore of a class-A app as a complete one. Evidence: `audits/evidence-drill-0243-2026-09-16/phase2-f10.txt`. **CLOSED 2026-09-16 — controller v0.244.0, proven live.** A unit restore refuses before anything is touched when the unit cannot return the app's drive-side files, and names the route that can. Fired live on demo-hp: `POST /backup/restore` for paperless-ngx → 302 with „Ez a mentés nem tartalmazza az alkalmazás fájljait, ezért nem állítjuk vissza az adatbázist föléjük — a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza …", and the app read `running` before AND after, so nothing was stopped and no trash was made unreachable. The database-and-settings-only path exists as a separately worded second step. Red-proof: disabling the guard fails `TestUnitRestore_RefusesWhenTheUnitCannotHoldTheFiles`. **RE-PROVEN 2026-09-16 on a FRESH box, and this time the refusal had somewhere to point:** after five photos were deleted, `POST /backup/restore` was refused with „…a fájlok így a helyükön maradnak. A fájlok a távoli másolatból állíthatók vissza: … „Teljes visszaállítás (fájlok + adatbázis)"", the app read `running` before AND after, and the wastebasket was untouched. The off-site route then returned all five photos — 200 with the exact uploaded sizes and sha256 IDENTICAL to the originals, 5/5, with a negative control. Evidence: `audits/evidence-backup-promise-2026-09-16/phaseE-photos.txt`. | **CLOSED 2026-09-16 — controller v0.244.0 (proven live on demo-hp)** |
|
||||
| **R-525** | **[P3-LOW] FileBrowser has its own login; putting it behind the dashboard session (traefik forwardAuth or Quantum proxy auth) is a new mechanism nobody has measured.** Filed 2026-09-15 by the P1-fixes task (B.5). R-513 closed the default-password hole with a generated password; a household still has two logins. **What it needs:** a spike on a scratch guest — forwardAuth to the controller session, and what FileBrowser Quantum does with a trusted header. | **READY — rank P3-LOW; owner: CC (spike)** |
|
||||
|
||||
@@ -1,3 +1,16 @@
|
||||
## i18n inventory + wire-contract allowlist entry (2026-09-17, localisation starter)
|
||||
|
||||
- **`i18n_inventory.py` (new, a survey — never fails a build).** Counts every customer-visible
|
||||
Hungarian string across the four repos, per surface (page templates incl. inline JS, controller Go
|
||||
classified shown/error/log/compared, hub customer mail, installer, catalog, guide, first-boot wizard),
|
||||
with the ten most-repeated strings, word-order and plural hazards, date layouts, and every place a
|
||||
Hungarian string is COMPARED rather than shown (same-line and through a package-level constant).
|
||||
Hungarian by accented letter (as `\u` escapes — the script is ASCII) or a 46-word ASCII list;
|
||||
positive and negative control per surface. Output: `documentation/audits/I18N-INVENTORY-2026-09-17.md`.
|
||||
- **`wire_contract_gate.py`:** allowlist entry for the report's new `language` field (controller
|
||||
v0.247.0; read by localisation slice 3, R-558). The gate passed without it — the token occurs hub-side
|
||||
only in a Go comment, and the gate does not strip comments: **R-555**.
|
||||
|
||||
## ISO v1.28.0 — the console stops showing the pairing code once the box is connected (2026-09-16, R-535) — PUBLISHED 2026-09-16
|
||||
|
||||
**The defect, measured on a fresh box 2026-09-16:** 25 minutes after a successful bind AND claim, with
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
#!/usr/bin/env python3
|
||||
"""i18n_inventory.py -- count every customer-visible Hungarian string across the Felhom repos.
|
||||
|
||||
Produces the numbers behind documentation/audits/I18N-INVENTORY-2026-09-17.md: one table per
|
||||
surface (page templates, controller Go, inline JS, hub customer mail, installer, catalog, guide,
|
||||
first-boot wizard), the ten most-repeated strings per surface, strings built with word order
|
||||
(Sprintf/concatenation), numeric-parameter strings (English needs plural forms), date layouts and
|
||||
size helpers, and every place a Hungarian string is COMPARED rather than shown.
|
||||
|
||||
It is a survey, not a gate: it never fails a build. It reads files as UTF-8 in Python, so the
|
||||
accented-grep traps (locale, kubectl exec) do not apply -- but the workspace rule for Hungarian
|
||||
search still holds, and each surface prints a POSITIVE control (an ASCII fragment of a Hungarian
|
||||
word known to be there, which the letter matcher must also flag) and a NEGATIVE control (a fragment
|
||||
known to be absent, and an ASCII-only line the matcher must NOT flag). A surface whose positive
|
||||
control reads 0 is reported as BROKEN, not as "no Hungarian".
|
||||
|
||||
This source is ASCII-only on purpose: the Hungarian letters are written as \\u escapes.
|
||||
|
||||
Usage:
|
||||
python3 scripts/i18n_inventory.py [--root /mnt/5_hdd/felhom.eu/git] [--json OUT.json]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
HU_LETTERS = set(
|
||||
"\u00e1\u00e9\u00ed\u00f3\u00f6\u0151\u00fa\u00fc\u0171"
|
||||
"\u00c1\u00c9\u00cd\u00d3\u00d6\u0150\u00da\u00dc\u0170"
|
||||
)
|
||||
|
||||
# ASCII-only Hungarian words: a string made only of these carries no accented letter and would be
|
||||
# missed by a letter search. Chosen to avoid English homographs ("Most", "Ma", "Online" are left out).
|
||||
ASCII_HU_SEED = [
|
||||
"Fut", "Nincs", "Igen", "Nem", "Hiba", "Mentve", "Rendben", "Adatok", "Napi", "Heti",
|
||||
"Havi", "Mindig", "Soha", "Tegnap", "Perc", "Letiltva", "Bekapcsolva", "Kikapcsolva",
|
||||
"Folyamatban", "Sikertelen", "Sikeres", "Tartalom", "Kapcsolat", "Vissza", "Megosztva",
|
||||
"Kezdeti", "Rendszer", "Csatlakozva", "Kulcs", "Szerkeszt", "Keres", "Megnyit", "Elrejt",
|
||||
"Mutasd", "Mentsd", "Kattints", "Nyisd", "Ismeretlen", "Szabad", "Foglalt",
|
||||
# added 2026-09-17 after the spike's by-eye review of what a letter search left behind
|
||||
"Megszakadt", "Elavult", "Csak", "kompatibilis", "Rendszermonitor", "Most nem",
|
||||
]
|
||||
SEED_RE = re.compile(r"\b(" + "|".join(ASCII_HU_SEED) + r")\b", re.I)
|
||||
|
||||
NEG_FRAGMENT = "qzxvkjq" # appears in no Felhom file
|
||||
NEG_ASCII_LINE = "Backup completed at 03:00, next run tomorrow."
|
||||
|
||||
|
||||
def has_hu(s: str) -> bool:
|
||||
return any(c in HU_LETTERS for c in s)
|
||||
|
||||
|
||||
def is_hu_text(s: str) -> bool:
|
||||
"""Hungarian by letter, or an ASCII-only string made of a seed word."""
|
||||
if has_hu(s):
|
||||
return True
|
||||
return bool(SEED_RE.search(s))
|
||||
|
||||
|
||||
def read(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# Go string-literal lexer (comments skipped, raw strings kept, line numbers tracked)
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def go_literals(src: str):
|
||||
"""Yield (line, literal_body, raw) for every string literal outside comments."""
|
||||
i, n, line = 0, len(src), 1
|
||||
while i < n:
|
||||
c = src[i]
|
||||
if c == "\n":
|
||||
line += 1
|
||||
i += 1
|
||||
elif src.startswith("//", i):
|
||||
j = src.find("\n", i)
|
||||
i = n if j < 0 else j
|
||||
elif src.startswith("/*", i):
|
||||
j = src.find("*/", i + 2)
|
||||
j = n if j < 0 else j + 2
|
||||
line += src.count("\n", i, j)
|
||||
i = j
|
||||
elif c == "'":
|
||||
j = i + 1
|
||||
while j < n and src[j] != "'":
|
||||
j += 2 if src[j] == "\\" else 1
|
||||
i = j + 1
|
||||
elif c == '"':
|
||||
j = i + 1
|
||||
while j < n and src[j] != '"' and src[j] != "\n":
|
||||
j += 2 if src[j] == "\\" else 1
|
||||
yield line, src[i + 1:j], False
|
||||
i = j + 1
|
||||
elif c == "`":
|
||||
j = src.find("`", i + 1)
|
||||
j = n if j < 0 else j
|
||||
yield line, src[i + 1:j], True
|
||||
line += src.count("\n", i, j)
|
||||
i = j + 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
|
||||
LOG_RE = re.compile(r"(\blog(ger)?\.|\.logger\.|\blg\.|Logf|Printf|Println|debugf|Debugf|infof|warnf)")
|
||||
ERR_RE = re.compile(r"(fmt\.Errorf|errors\.New)\(")
|
||||
CMP_RE = re.compile(r'(==\s*"|!=\s*"|\bcase\s+"|strings\.(Contains|HasPrefix|HasSuffix|EqualFold|Index)\()')
|
||||
FMT_VERB_RE = re.compile(r"%[-+# 0-9.]*[sdvqfx]")
|
||||
NUM_VERB_RE = re.compile(r"%[-+# 0-9.]*d")
|
||||
|
||||
|
||||
def classify_go(line_text: str, body: str) -> str:
|
||||
if body.lstrip().startswith(("[DEBUG]", "[INFO]", "[WARN]", "[ERROR]")) or LOG_RE.search(line_text):
|
||||
return "log"
|
||||
if CMP_RE.search(line_text):
|
||||
return "compare"
|
||||
if ERR_RE.search(line_text):
|
||||
return "error"
|
||||
return "shown"
|
||||
|
||||
|
||||
def go_package_consts(src: str):
|
||||
"""Yield (name, value) for PACKAGE-LEVEL const/var string declarations only -- a local `msg := ...`
|
||||
reused as a name in another package is not the same value and must not be matched."""
|
||||
in_block = False
|
||||
for l in src.split("\n"):
|
||||
if re.match(r"^(const|var)\s*\($", l):
|
||||
in_block = True
|
||||
continue
|
||||
if in_block and l.startswith(")"):
|
||||
in_block = False
|
||||
continue
|
||||
m = (re.match(r'^\t([A-Za-z_]\w*)\s*(?:string\s*)?=\s*"((?:[^"\\]|\\.)*)"', l) if in_block
|
||||
else re.match(r'^(?:const|var)\s+([A-Za-z_]\w*)\s*(?:string\s*)?=\s*"((?:[^"\\]|\\.)*)"', l))
|
||||
if m:
|
||||
yield m.group(1), m.group(2)
|
||||
|
||||
|
||||
def go_indirect_compares(files, root: Path):
|
||||
"""Hungarian held in a package-level constant/variable, then COMPARED through that name in the
|
||||
same package -- the shape a same-line literal search cannot see (handlers.go
|
||||
offboxStaleWarningMarker is the case that prompted it)."""
|
||||
bydir = collections.defaultdict(list)
|
||||
for f in files:
|
||||
bydir[f.parent].append(f)
|
||||
out = []
|
||||
for d, fs in bydir.items():
|
||||
srcs = {f: read(f) for f in fs}
|
||||
names = {}
|
||||
for f, src in srcs.items():
|
||||
for n, v in go_package_consts(src):
|
||||
if is_hu_text(v):
|
||||
names[n] = (str(f.relative_to(root)), v)
|
||||
if not names:
|
||||
continue
|
||||
alt = "|".join(map(re.escape, names))
|
||||
use_re = re.compile(r"(strings\.(?:Contains|HasPrefix|HasSuffix|EqualFold|Index)\([^)]*\b(%s)\b|[=!]=\s*(%s)\b|\b(%s)\s*[=!]=)" % (alt, alt, alt))
|
||||
for f, src in srcs.items():
|
||||
for i, l in enumerate(src.split("\n"), 1):
|
||||
if l.lstrip().startswith("//"):
|
||||
continue
|
||||
m = use_re.search(l)
|
||||
if m:
|
||||
n = m.group(2) or m.group(3) or m.group(4)
|
||||
out.append({"file": str(f.relative_to(root)), "line": i, "name": n,
|
||||
"defined": names[n][0], "text": names[n][1][:120]})
|
||||
return out
|
||||
|
||||
|
||||
def scan_go_files(files, root: Path):
|
||||
rows = []
|
||||
for f in files:
|
||||
src = read(f)
|
||||
lines = src.split("\n")
|
||||
for ln, body, raw in go_literals(src):
|
||||
if not is_hu_text(body):
|
||||
continue
|
||||
# A raw literal that is itself an embedded HTML page/template: count it, flag it.
|
||||
lt = lines[ln - 1] if ln - 1 < len(lines) else ""
|
||||
kind = classify_go(lt, body)
|
||||
rows.append({
|
||||
"file": str(f.relative_to(root)), "line": ln, "text": body[:160], "kind": kind,
|
||||
"raw": raw, "multiline": "\n" in body,
|
||||
"format": bool(FMT_VERB_RE.search(body)),
|
||||
"numeric": bool(NUM_VERB_RE.search(body)),
|
||||
"concat": bool(re.search(r'"\s*\+|\+\s*"', lt)) and not raw,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# HTML template scanner
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
|
||||
ACTION_RE = re.compile(r"\{\{.*?\}\}", re.S)
|
||||
CONTROL_RE = re.compile(r"\{\{-?\s*(if|else|end|range|with|define|block|template)\b.*?\}\}", re.S)
|
||||
SCRIPT_RE = re.compile(r"<script\b[^>]*>(.*?)</script>", re.S | re.I)
|
||||
STYLE_RE = re.compile(r"<style\b[^>]*>(.*?)</style>", re.S | re.I)
|
||||
TAG_RE = re.compile(r"<[a-zA-Z!/][^>]*>", re.S)
|
||||
ATTR_RE = re.compile(r'\s([a-zA-Z][a-zA-Z0-9:_-]*)\s*=\s*"([^"]*)"', re.S)
|
||||
JS_STR_RE = re.compile(r"'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"")
|
||||
NUM_PARAM_RE = re.compile(r"\{\{\s*(len\b|\.\w*(Count|Num|Total|Days|Hours|Minutes|Size|N)\b)")
|
||||
|
||||
|
||||
def line_of(src: str, pos: int) -> int:
|
||||
return src.count("\n", 0, pos) + 1
|
||||
|
||||
|
||||
def scan_template(f: Path, root: Path):
|
||||
src = read(f)
|
||||
rel = str(f.relative_to(root))
|
||||
out = {"file": rel, "hu_lines": sum(1 for l in src.split("\n") if has_hu(l)),
|
||||
"text": [], "attr": [], "js": [], "compare": [], "numeric": []}
|
||||
|
||||
# Mask script/style bodies (keep newlines so line numbers survive).
|
||||
masked = src
|
||||
for m in list(SCRIPT_RE.finditer(src)):
|
||||
for jm in JS_STR_RE.finditer(m.group(1)):
|
||||
s = jm.group(0)[1:-1]
|
||||
if is_hu_text(ACTION_RE.sub("", s)):
|
||||
out["js"].append({"line": line_of(src, m.start(1) + jm.start()), "text": s[:160]})
|
||||
def blank(m):
|
||||
return re.sub(r"[^\n]", " ", m.group(0))
|
||||
masked = SCRIPT_RE.sub(blank, masked)
|
||||
masked = STYLE_RE.sub(blank, masked)
|
||||
|
||||
# A compare is an eq/ne action whose OWN string arguments are Hungarian -- parsed inside the one
|
||||
# action, never across the text between two actions.
|
||||
for m in ACTION_RE.finditer(masked):
|
||||
act = m.group(0)
|
||||
if not re.search(r"\b(eq|ne)\b", act):
|
||||
continue
|
||||
for lit in re.findall(r'"((?:[^"\\]|\\.)*)"', act):
|
||||
if is_hu_text(lit):
|
||||
out["compare"].append({"line": line_of(src, m.start()), "text": lit})
|
||||
|
||||
for tm in TAG_RE.finditer(masked):
|
||||
for am in ATTR_RE.finditer(tm.group(0)):
|
||||
name, val = am.group(1).lower(), am.group(2)
|
||||
if name in ("class", "id", "href", "src", "style", "name", "type", "for", "action", "method"):
|
||||
continue
|
||||
if is_hu_text(ACTION_RE.sub("", val)):
|
||||
out["attr"].append({"line": line_of(src, tm.start()), "attr": name, "text": val[:160]})
|
||||
|
||||
# Text nodes: what lies between tags, split at control actions (if/range/...), with the
|
||||
# remaining value actions kept in the message (they are its parameters).
|
||||
text_only = TAG_RE.sub(lambda m: "\x00" + re.sub(r"[^\n]", " ", m.group(0))[1:], masked)
|
||||
pos = 0
|
||||
for chunk in text_only.split("\x00"):
|
||||
start = pos
|
||||
pos += len(chunk) + 1
|
||||
for piece in CONTROL_RE.split(chunk):
|
||||
if piece is None:
|
||||
continue
|
||||
p = piece.strip()
|
||||
if not p or p in ("if", "else", "end", "range", "with", "define", "block", "template"):
|
||||
continue
|
||||
visible = ACTION_RE.sub("", p).strip()
|
||||
if visible and is_hu_text(visible):
|
||||
ln = line_of(src, start + max(chunk.find(p[:20]), 0))
|
||||
out["text"].append({"line": ln, "text": " ".join(p.split())[:160]})
|
||||
if NUM_PARAM_RE.search(p):
|
||||
out["numeric"].append({"line": ln, "text": " ".join(p.split())[:160]})
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
# Other surfaces
|
||||
# ---------------------------------------------------------------------------------------------------
|
||||
|
||||
def walk_yaml(node, path=""):
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
yield from walk_yaml(v, f"{path}.{k}" if path else str(k))
|
||||
elif isinstance(node, list):
|
||||
for v in node:
|
||||
yield from walk_yaml(v, path + "[]")
|
||||
elif isinstance(node, str):
|
||||
yield path, node
|
||||
|
||||
|
||||
def controls(name: str, blobs: list[str], pos_fragment: str):
|
||||
"""Positive: pos_fragment (ASCII) occurs, and some line holding it is flagged Hungarian.
|
||||
Negative: NEG_FRAGMENT occurs nowhere, and an ASCII English line is not flagged."""
|
||||
pos_lines = [l for b in blobs for l in b.split("\n") if pos_fragment in l]
|
||||
pos_flagged = sum(1 for l in pos_lines if is_hu_text(l))
|
||||
neg = sum(b.count(NEG_FRAGMENT) for b in blobs)
|
||||
ok = len(pos_lines) > 0 and pos_flagged > 0 and neg == 0 and not is_hu_text(NEG_ASCII_LINE)
|
||||
return {"surface": name, "positive_fragment": pos_fragment, "positive_lines": len(pos_lines),
|
||||
"positive_flagged": pos_flagged, "negative_fragment": NEG_FRAGMENT, "negative_hits": neg,
|
||||
"negative_ascii_line_flagged": is_hu_text(NEG_ASCII_LINE), "ok": ok}
|
||||
|
||||
|
||||
def top10(strings):
|
||||
c = collections.Counter(" ".join(s.split()) for s in strings)
|
||||
return [(t, n) for t, n in c.most_common(10) if n > 1]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--root", default="/mnt/5_hdd/felhom.eu/git")
|
||||
ap.add_argument("--json", default=None)
|
||||
args = ap.parse_args()
|
||||
root = Path(args.root)
|
||||
ctl = root / "felhom-controller" / "controller"
|
||||
hub = root / "felhom.eu" / "hub"
|
||||
report = {"script_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "surfaces": {}}
|
||||
ctrls = []
|
||||
|
||||
# 1-3. Controller templates (+ inline JS) -------------------------------------------------------
|
||||
tdir = ctl / "internal" / "web" / "templates"
|
||||
tfiles = sorted(tdir.glob("*.html"))
|
||||
tres = [scan_template(f, root) for f in tfiles]
|
||||
report["surfaces"]["templates"] = tres
|
||||
ctrls.append(controls("controller templates", [read(f) for f in tfiles], "pult"))
|
||||
|
||||
# 2. Controller Go (non-test), excluding the first-boot wizard ------------------------------------
|
||||
gofiles = sorted(p for p in ctl.rglob("*.go")
|
||||
if not p.name.endswith("_test.go") and "/internal/setup/" not in str(p))
|
||||
gores = scan_go_files(gofiles, root)
|
||||
report["surfaces"]["controller_go"] = gores
|
||||
indirect = go_indirect_compares(gofiles, root)
|
||||
report["surfaces"]["controller_go_indirect_compares"] = indirect
|
||||
ctrls.append(controls("controller Go", [read(f) for f in gofiles], "ment"))
|
||||
|
||||
# 4. Hub customer surfaces ------------------------------------------------------------------------
|
||||
hub_customer = sorted(list((hub / "internal" / "notify").glob("*.go"))
|
||||
+ list((hub / "internal" / "web").glob("selfbind*.go"))
|
||||
+ [hub / "internal" / "web" / "customer_reset.go", hub / "internal" / "claim" / "engine.go"])
|
||||
hub_customer = [p for p in hub_customer if p.exists() and not p.name.endswith("_test.go")]
|
||||
hubres = scan_go_files(hub_customer, root)
|
||||
report["surfaces"]["hub_customer_go"] = hubres
|
||||
hub_all = sorted(p for p in hub.rglob("*.go") if not p.name.endswith("_test.go"))
|
||||
hub_all_rows = scan_go_files(hub_all, root)
|
||||
hub_tpl = [scan_template(f, root) for f in sorted((hub / "internal" / "web" / "templates").glob("*.html"))]
|
||||
report["surfaces"]["hub_operator_templates"] = hub_tpl
|
||||
ctrls.append(controls("hub customer Go", [read(f) for f in hub_customer], "ment"))
|
||||
|
||||
# 5. Installer ------------------------------------------------------------------------------------
|
||||
banner = root / "felhom.eu" / "scripts" / "iso" / "felhom-bootstrap.sh"
|
||||
btext = read(banner)
|
||||
banner_lines = [{"line": i + 1, "text": l.strip()[:160]} for i, l in enumerate(btext.split("\n")) if is_hu_text(l)]
|
||||
dl = root / "felhom.eu" / "website" / "letoltes.html"
|
||||
dlres = scan_template(dl, root) if dl.exists() else None
|
||||
report["surfaces"]["installer_banner"] = banner_lines
|
||||
report["surfaces"]["download_page"] = dlres
|
||||
ctrls.append(controls("installer banner", [btext], "Felhom"))
|
||||
|
||||
# 6. Catalog --------------------------------------------------------------------------------------
|
||||
cat = []
|
||||
catfiles = sorted((root / "app-catalog-felhom.eu" / "templates").glob("*/.felhom.yml"))
|
||||
for f in catfiles:
|
||||
try:
|
||||
doc = yaml.safe_load(read(f)) or {}
|
||||
except yaml.YAMLError as e:
|
||||
cat.append({"file": str(f.relative_to(root)), "path": "<YAML ERROR>", "text": str(e)[:120]})
|
||||
continue
|
||||
for p, v in walk_yaml(doc):
|
||||
if is_hu_text(v):
|
||||
cat.append({"file": str(f.relative_to(root)), "path": re.sub(r"\[\]", "[]", p), "text": v[:160],
|
||||
"words": len(v.split())})
|
||||
report["surfaces"]["catalog"] = cat
|
||||
ctrls.append(controls("catalog", [read(f) for f in catfiles], "Alkalmaz"))
|
||||
|
||||
# 7. Guide ----------------------------------------------------------------------------------------
|
||||
guide = root / "felhom.eu" / "documentation" / "runbooks" / "VOLUNTEER-first-hour.md"
|
||||
gtext = read(guide)
|
||||
paras = [p for p in re.split(r"\n\s*\n", gtext) if p.strip()]
|
||||
report["surfaces"]["guide"] = {"file": str(guide.relative_to(root)), "paragraphs": len(paras),
|
||||
"hu_paragraphs": sum(1 for p in paras if is_hu_text(p)),
|
||||
"words": len(gtext.split())}
|
||||
ctrls.append(controls("guide", [gtext], "Felhom"))
|
||||
|
||||
# 8. First-boot wizard ----------------------------------------------------------------------------
|
||||
sdir = ctl / "internal" / "setup"
|
||||
stpl = [scan_template(f, root) for f in sorted((sdir / "templates").glob("*.html"))]
|
||||
sgo = scan_go_files(sorted(p for p in sdir.rglob("*.go") if not p.name.endswith("_test.go")), root)
|
||||
report["surfaces"]["setup_wizard"] = {"templates": stpl, "go": sgo}
|
||||
|
||||
# Dates and sizes ---------------------------------------------------------------------------------
|
||||
fmt_sites = []
|
||||
for f in sorted((ctl / "internal" / "web").glob("*.go")):
|
||||
if f.name.endswith("_test.go"):
|
||||
continue
|
||||
for i, l in enumerate(read(f).split("\n"), 1):
|
||||
for m in re.finditer(r'\.Format\("([^"]*)"\)', l):
|
||||
fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1)})
|
||||
for f in tfiles:
|
||||
for i, l in enumerate(read(f).split("\n"), 1):
|
||||
for m in re.finditer(r'\.Format\s+"([^"]*)"|Format\s+"([^"]*)"', l):
|
||||
fmt_sites.append({"file": str(f.relative_to(root)), "line": i, "layout": m.group(1) or m.group(2)})
|
||||
fmtmb = []
|
||||
for f in tfiles + sorted((ctl / "internal" / "web").glob("*.go")):
|
||||
if f.name.endswith("_test.go"):
|
||||
continue
|
||||
for i, l in enumerate(read(f).split("\n"), 1):
|
||||
if "fmtMB" in l:
|
||||
fmtmb.append({"file": str(f.relative_to(root)), "line": i})
|
||||
ctl_fmt_elsewhere = 0
|
||||
for f in ctl.rglob("*.go"):
|
||||
if f.name.endswith("_test.go") or "/internal/web/" in str(f):
|
||||
continue
|
||||
ctl_fmt_elsewhere += len(re.findall(r'\.Format\("', read(f)))
|
||||
report["dates"] = fmt_sites
|
||||
report["fmtMB"] = fmtmb
|
||||
report["format_calls_outside_web"] = ctl_fmt_elsewhere
|
||||
report["controls"] = ctrls
|
||||
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
# Markdown summary
|
||||
# ---------------------------------------------------------------------------------------------
|
||||
P = print
|
||||
P(f"script sha256: {report['script_sha256']}\n")
|
||||
P("## Controls (ASCII fragment, positive + negative)\n")
|
||||
P("| surface | positive fragment | lines holding it | of those flagged | negative fragment hits | ASCII English line flagged | verdict |")
|
||||
P("|---|---|---|---|---|---|---|")
|
||||
for c in ctrls:
|
||||
P(f"| {c['surface']} | `{c['positive_fragment']}` | {c['positive_lines']} | {c['positive_flagged']} | "
|
||||
f"{c['negative_hits']} | {c['negative_ascii_line_flagged']} | {'ok' if c['ok'] else 'BROKEN'} |")
|
||||
|
||||
def tsum(rs):
|
||||
return (sum(r["hu_lines"] for r in rs), sum(len(r["text"]) for r in rs), sum(len(r["attr"]) for r in rs),
|
||||
sum(len(r["js"]) for r in rs), sum(len(r["compare"]) for r in rs), sum(len(r["numeric"]) for r in rs),
|
||||
sum(1 for r in rs if r["hu_lines"] or r["text"] or r["attr"] or r["js"]))
|
||||
|
||||
P("\n## Controller page templates\n")
|
||||
hl, tx, at, js, cm, nu, withhu = tsum(tres)
|
||||
P(f"{len(tres)} files; {withhu} carry Hungarian. Lines with an accented letter: **{hl}**. "
|
||||
f"Strings: **{tx} text runs + {at} attribute values + {js} inline-JS literals = {tx + at + js}**. "
|
||||
f"Template compares against Hungarian: {cm}. Text runs with a numeric parameter: {nu}.\n")
|
||||
P("| template | HU lines | text | attr | JS | compare |")
|
||||
P("|---|---|---|---|---|---|")
|
||||
for r in sorted(tres, key=lambda r: -(len(r["text"]) + len(r["attr"]) + len(r["js"]))):
|
||||
P(f"| {Path(r['file']).name} | {r['hu_lines']} | {len(r['text'])} | {len(r['attr'])} | {len(r['js'])} | {len(r['compare'])} |")
|
||||
P("\nMost repeated (text + attr):")
|
||||
for t, n in top10([x["text"] for r in tres for x in r["text"] + r["attr"]]):
|
||||
P(f"- {n}x `{t}`")
|
||||
|
||||
def gosum(rows, label):
|
||||
k = collections.Counter(r["kind"] for r in rows)
|
||||
files = len({r["file"] for r in rows})
|
||||
P(f"\n## {label}\n")
|
||||
P(f"**{len(rows)} literals in {files} files.** shown: {k['shown']}, error (may reach a page): {k['error']}, "
|
||||
f"log only: {k['log']}, compared: {k['compare']}. Format strings with parameters (non-log): "
|
||||
f"{sum(1 for r in rows if r['format'] and r['kind'] != 'log')}; numeric %d (non-log, plural risk): "
|
||||
f"{sum(1 for r in rows if r['numeric'] and r['kind'] != 'log')}; concatenated (non-log): "
|
||||
f"{sum(1 for r in rows if r['concat'] and r['kind'] != 'log')}; raw multi-line blocks: "
|
||||
f"{sum(1 for r in rows if r['multiline'])}.\n")
|
||||
byfile = collections.Counter(r["file"] for r in rows if r["kind"] != "log")
|
||||
P("| file (top 15, non-log) | literals |")
|
||||
P("|---|---|")
|
||||
for fname, n in byfile.most_common(15):
|
||||
P(f"| {fname} | {n} |")
|
||||
P("\nMost repeated (non-log):")
|
||||
for t, n in top10([r["text"] for r in rows if r["kind"] != "log"]):
|
||||
P(f"- {n}x `{t}`")
|
||||
return k
|
||||
|
||||
gosum(gores, "Controller Go (non-test, first-boot wizard excluded)")
|
||||
P("\n### Compared, not shown (controller Go)\n")
|
||||
for r in gores:
|
||||
if r["kind"] == "compare":
|
||||
P(f"- {r['file']}:{r['line']} `{r['text']}`")
|
||||
for r in indirect:
|
||||
P(f"- {r['file']}:{r['line']} via `{r['name']}` (defined {r['defined']}) `{r['text']}`")
|
||||
P("\n### Compared, not shown (templates)\n")
|
||||
for r in tres:
|
||||
for x in r["compare"]:
|
||||
P(f"- {r['file']}:{x['line']} `{x['text']}`")
|
||||
|
||||
gosum(hubres, "Hub customer surfaces (notify, self-bind, reset, claim)")
|
||||
hk = collections.Counter(r["kind"] for r in hub_all_rows)
|
||||
hl2, tx2, at2, js2, _, _, _ = tsum(hub_tpl)
|
||||
P(f"\nFor scale, NOT in scope: all hub Go non-test = {len(hub_all_rows)} literals "
|
||||
f"(shown {hk['shown']}, error {hk['error']}, log {hk['log']}, compare {hk['compare']}); "
|
||||
f"hub operator templates = {tx2 + at2 + js2} strings, {hl2} HU lines.")
|
||||
|
||||
P("\n## Installer\n")
|
||||
P(f"Console banner `{banner.relative_to(root)}`: **{len(banner_lines)}** Hungarian lines.")
|
||||
if dlres:
|
||||
P(f"Download page `{dl.relative_to(root)}`: {len(dlres['text'])} text + {len(dlres['attr'])} attr + "
|
||||
f"{len(dlres['js'])} JS = **{len(dlres['text']) + len(dlres['attr']) + len(dlres['js'])}** strings, "
|
||||
f"{dlres['hu_lines']} HU lines.")
|
||||
|
||||
P("\n## Catalog\n")
|
||||
byfield = collections.Counter(re.sub(r"^.*?\b(app_info\.\w+|deploy_fields\[\]\.\w+|\w+)$", r"\1", r["path"]) for r in cat)
|
||||
apps = len({r["file"] for r in cat})
|
||||
words = sum(r.get("words", 0) for r in cat)
|
||||
P(f"{len(catfiles)} `.felhom.yml`; {apps} carry Hungarian; **{len(cat)} strings, ~{words} words**.\n")
|
||||
P("| field | strings |")
|
||||
P("|---|---|")
|
||||
for fld, n in byfield.most_common(20):
|
||||
P(f"| {fld} | {n} |")
|
||||
|
||||
g = report["surfaces"]["guide"]
|
||||
P(f"\n## Guide\n\n`{g['file']}`: {g['paragraphs']} paragraphs ({g['hu_paragraphs']} Hungarian), ~{g['words']} words.")
|
||||
|
||||
P("\n## First-boot wizard (reported separately -- never merged into the dashboard count)\n")
|
||||
shl, stx, sat, sjs, scm, _, _ = tsum(stpl)
|
||||
sk = collections.Counter(r["kind"] for r in sgo)
|
||||
P(f"{len(stpl)} templates: {shl} HU lines; {stx} text + {sat} attr + {sjs} JS = **{stx + sat + sjs}** strings. "
|
||||
f"Go: {len(sgo)} literals (shown {sk['shown']}, error {sk['error']}, log {sk['log']}, compare {sk['compare']}).")
|
||||
|
||||
P("\n## Dates and sizes\n")
|
||||
lay = collections.Counter((Path(x["file"]).suffix, x["layout"]) for x in fmt_sites)
|
||||
P("| where | layout | sites |")
|
||||
P("|---|---|---|")
|
||||
for (suf, l), n in sorted(lay.items(), key=lambda kv: -kv[1]):
|
||||
P(f"| {'template' if suf == '.html' else 'internal/web Go'} | `{l}` | {n} |")
|
||||
P(f"\n`fmtMB` sites: {sum(1 for x in fmtmb if x['file'].endswith('.html'))} in templates, "
|
||||
f"{sum(1 for x in fmtmb if x['file'].endswith('.go'))} in Go. `.Format(\"` calls in the controller outside "
|
||||
f"internal/web: {ctl_fmt_elsewhere}.")
|
||||
|
||||
if args.json:
|
||||
Path(args.json).write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
bad = [c for c in ctrls if not c["ok"]]
|
||||
if bad:
|
||||
P("\nCONTROLS BROKEN: " + ", ".join(c["surface"] for c in bad), file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -208,6 +208,14 @@ ALLOWLIST = {
|
||||
"reached, never on a skip, a missing snapshot or a restore error, so 'presence is not success' "
|
||||
"does not bite here. **Delete with its siblings.**"),
|
||||
|
||||
(_CH, "language"): (
|
||||
"controller v0.247.0 (i18n spike): the household's dashboard language, published so the hub's "
|
||||
"customer e-mails can follow it — plan slice 3 in documentation/architecture/10-localisation.md. "
|
||||
"No hub release reads it yet; the hub stores the raw report, so it is visible there. RECORDED "
|
||||
"HERE ALTHOUGH THE GATE PASSES WITHOUT IT: the token `language` occurs hub-side only inside a "
|
||||
"Go COMMENT (hub/internal/web/configs.go), and receiver_tokens() does not strip comments — the "
|
||||
"row filed 2026-09-17 for that blind spot names this field as its found case. **Delete this "
|
||||
"entry when slice 3 reads the field.**"),
|
||||
(_CH, "offsite.last_integrity_depth"): (
|
||||
"R-399, controller v0.228.0: the DEPTH the verdict was reached at, published beside "
|
||||
"last_integrity_ok above and unconsumed for the identical reason. It matters because 'checked, "
|
||||
|
||||
Reference in New Issue
Block a user