Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
79ce8eb
feat(i18n): traduzir analysis/** e travar o pacote no ratchet (fase 3…
Aug 13, 2026
5ddb35d
feat(i18n): traduzir a casca da UI -- gui.py e ui_coordinator.py (fas…
Aug 13, 2026
f744065
feat(i18n): traduzir core/recording/** (fase 3, lote 3a)
Aug 13, 2026
d752519
feat(i18n): traduzir core/project/** (fase 3, lote 3b)
Aug 13, 2026
6fbee7f
fix(i18n): scanner acusava docstring de atributo como string de inter…
Aug 13, 2026
2b3baeb
feat(i18n): traduzir core/services/** (fase 3, lote 3c)
Aug 14, 2026
28ca1bb
feat(i18n): fechar core/** (fase 3, lote 3d)
Aug 14, 2026
960054c
feat(i18n): wizard -- models, dialogo e design experimental (fase 3, …
Aug 14, 2026
228c126
docs(i18n): registrar no guia as regras aprendidas na migracao
Aug 14, 2026
46fbc9b
feat(i18n): traduzir confirmation_step e live_config_step (lote 4b)
Aug 14, 2026
97d1225
feat(i18n): traduzir model_selection_step e detection_step (lote 4c)
Aug 14, 2026
a026030
feat(i18n): fechar ui/wizard (lote 4d)
Aug 14, 2026
4494cb4
feat(i18n): traduzir os dialogos pequenos de ui/dialogs (lote 5a)
Aug 14, 2026
2497c0f
docs(i18n): registrar o progresso do lote 5 e corrigir a contagem do 5a
Aug 14, 2026
90ad291
feat(i18n): traduzir block_detail_dialog e live_analysis_dialog (lote…
Aug 14, 2026
abbaa2d
feat(i18n): traduzir os dialogos de criacao/importacao (lote 5c)
Aug 14, 2026
cbbe317
feat(i18n): traduzir os dialogos medios de ui/dialogs (lote 5d)
Aug 14, 2026
8dbb078
feat(i18n): traduzir os oito dialogos finais de ui/dialogs (lote 5e)
Aug 15, 2026
1908e37
docs(i18n): fechar a migracao -- ratchet, guia e CHANGELOG
Aug 15, 2026
7702faa
test: exercitar o formatador real em test_analysis_metadata_display
Aug 15, 2026
42d8421
fix(detector): nao mascarar violacao de faixa de track_buffer como er…
Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Migração de idioma concluída (i18n — fases 2/3 e 3/3)

- **A interface inteira passou para inglês como língua-fonte.** As fases 2 e 3
cobriram `coordinators/`, `ui/components/`, `io/`, `plugins/`, `utils/`,
`ui/builders/`, `analysis/`, `core/`, `ui/gui.py`, `ui/ui_coordinator.py`,
`ui/wizard/` e `ui/dialogs/` — cerca de 1900 msgids no total. `i18n_scan.py`
está em zero e o ratchet
(`tests/i18n/test_no_untranslated_literals.py`) passou a cobrir `src/zebtrack`
inteiro, arquivos futuros incluídos.
- **A migração revelou defeitos que a tradução só tornou visíveis.** Os de maior
impacto:
- **Rótulos citados de memória em textos de ajuda.** O guia pós-criação de
projeto retipava seis nomes de aba/botão como prosa e **dois já estavam
errados antes da migração** — um deles nomeava um botão que não existe no
código. Agora interpolam o msgid do próprio widget.
- **Ramificação pelo texto de uma exceção** em
`detector_service._validate_range`: traduzir a mensagem teria reclassificado
toda violação de faixa como "não é um número". A checagem de faixa saiu do
`try`.
- **Marcador "⭐ Recomendado" anexado para exibir e retirado por
`_strip_annotation()`** no wizard: traduzir só a exibição colaria o marcador
no nome do peso e `validate()` recusaria um peso escolhido na própria lista.
- **Identidade de template obtida por `.replace('Template carregado: ', '')`
sobre o texto renderizado** — traduzir o prefixo o deixaria dentro do resumo.
- **Status de UI em `coordinators/` e `core/recording/`** que o scanner nunca
viu por não ter acento: "Aguardando sinal externo... (porta N)" e quatro
strings do painel ao vivo, essas últimas ao lado de chamadas `_()` já
traduzidas no mesmo rótulo — a linha de status trocava de idioma no meio da
sessão.
- **Cabeçalhos de coluna do `.xlsx` e uma legenda de figura em português** no
meio de arquivos 100% ingleses. Cabeçalhos são esquema, não texto: agora são
inglês fixo, sem `_()`, para que o mesmo dado não gere planilhas de esquemas
diferentes conforme a máquina.
- **Grafias duplicadas da mesma mensagem** unificadas num msgid só: duas
redações da validação det/seg, `pyserial não instalado`/`não disponível`,
`Total de Vídeos`/`Total de vídeos`, `Porta Arduino:`/`Porta do Arduino:`.
- **Asserções de teste vácuas encontradas de passagem.** Quatro no wizard
(`or "Template" in ...`, dois `"indispon" not in ...`) passariam mesmo se o
aviso testado fosse exibido; e quatro testes dirigiam widgets pelo rótulo em
português, o que fazia `test_animal_method_hint_cleared_for_seg` passar pelo
motivo errado. Todas reancoradas.
- **Contratos de persistência não foram traduzidos**, e isso é deliberado:
`Grupo_*`/`Dia_*`/`Sujeito_*`, as chaves de `session_duration_overrides`, a aba
`por_animal` e as chaves do dict `report`. Traduzi-los não produziria um app em
inglês, e sim um app incapaz de ler os projetos que ele mesmo gravou. A lista
vive em `scripts/i18n_allowlist.txt`.

#### Limitação conhecida

- **O scanner só enxerga português ACENTUADO.** `Salvar`, `Nenhum video`,
`Remover`, `dias` e afins passam por ele e pelo ratchet sem serem vistos —
foi exatamente assim que os status de `coordinators/` e `core/recording/`
sobreviveram dentro de pacotes já travados. Uma varredura dedicada a português
sem acento continua pendente.

### Interface em inglês por padrão, português selecionável (i18n — fase 1/3)

- **O idioma agora é uma escolha do pesquisador, não do sistema operacional.** Na
Expand Down
170 changes: 165 additions & 5 deletions docs/guides/developer/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,33 @@ _("Removed {count} videos").format(count=n) # correct
_(f"Removed {n} videos") # never matches; do not do this
```

One msgid per **complete sentence**. A sentence assembled from fragments cannot
be reordered by a translator, and a fragment with a noun spliced into it cannot
even be made to agree: Portuguese inflects for the spliced word (`Não há {X}
registrad**a**`, `uma {entrada/saída} deveria...`), which no substitution can
guarantee. Write one full sentence per case instead:

```python
# Wrong: the adjective has to agree with whatever `label` holds.
_("There is no {label} recorded for this video.").format(label=label)

# Right: one msgid per case.
{"arena": _("There is no arena recorded for this video."),
"rois": _("There are no ROIs recorded for this video.")}[asset]
```

## Plurals

There is no `ngettext` in this project and the `_pairs` files carry no plural
forms. Use two plain msgids selected by a comparison:

```python
_("Maximum supported: 1 aquarium.") if n == 1 else _("Maximum supported: {count} aquariums.").format(count=n)
```

A parenthetical like `aquário(s)` or `frame(s) descartado(s)` is a dodge around
the plural, not a style — replace it with the pair.

## The two domains

| Domain | Covers | Reached through |
Expand Down Expand Up @@ -116,6 +143,35 @@ yesterday. They are listed in `scripts/i18n_allowlist.txt`:
Where such a token is *displayed*, translate at the display boundary — render
`_("Unassigned")` for a stored `Sem_Grupo` — and never change what is stored.

The allowlist covers the tokens that already exist. The general rule behind it:

> **Written to disk or compared in code ⇒ plain English, no `_()`.
> Rendered to a person ⇒ `_()`.**

The same sentence can have both roles and then needs both treatments. In
`core/services/weight_manager.py`, `details["last_conversion_error"]` is stored
in the weights config (English) while the `OpenVINOExportError` raised beside it
reaches the operator (translated).

Categories that are *stored*, and therefore stay English even though they read
like copy:

- **Spreadsheet column headers.** The geotaxis columns in
`analysis/data_transformer.py` used to emit `Fundo`/`Meio`/`Superfície` into
`_summary.xlsx` while every other header in the file was English. A header that
changed with `ui.language` would give the same analysis a different schema on
each machine.
- **`metadata.json` of a converted model** (`weight_manager`), and the built-in
analysis-profile name in `project.json` — which had drifted into two spellings,
`"default"` from the canonical factory and `"Padrão"` from a fallback.
- **`Field(description=...)` on Pydantic models**, plus their validator messages.
A description is evaluated in the class body, where `_()` is forbidden anyway
(see below); treat the whole schema layer as English. `ui/wizard/models.py` and
`core/project/schemas.py` follow this.
- **Provenance tags** never rendered — `reason="Manual refresh"`,
`reason="zones_concluded"` — and `__str__` implementations used for logs,
especially when they print a persisted `enum.value`.

## Exception messages

Exception text that only reaches logs and tracebacks stays in plain English and
Expand All @@ -127,6 +183,80 @@ Never branch on the words inside an exception message. `except SomeError:`
survives translation; `if "caminho não definido" in str(e)` does not, and it
fails by silently taking the wrong branch.

Usually the branch exists because two different failures are raised from the
same `try`. Restructure so they cannot be confused, rather than matching prose.
`core/services/detector_service.py` had:

```python
try:
val = float(value)
if val < min_val or val > max_val:
raise ValueError(f"{param} deve estar entre {min_val} e {max_val}")
except (TypeError, ValueError) as e:
if isinstance(e, ValueError) and "deve estar entre" in str(e):
raise # range error: re-raise as-is
raise ValueError(f"{param} deve ser um número válido") from e
```

Translating that message would have relabelled every range violation as "is not
a number". The fix was to move the range check **out** of the `try`, so the
`except` only ever sees a parse failure and no distinction is needed.

## Never branch on displayed text either

The same rule applies to labels. Anchor on the widget, the frame, or a real
constant — never on what the widget says. This has already broken in production:
`navigate_to_processing_reports_tab` looked its tab up by label, phase 1
translated that label, and the tab simply stopped opening, with a `log.warning`
as the only symptom.

Two related traps:

- **A string that is both displayed and compared** must come from one function,
so the two sides can never drift (see `weight_inherit_label()` in
`ui/components/project_model_configuration_panel.py`).
- **Help text that quotes a button or tab name.** Retyping the label is how it
rots: the post-creation guide in `core/project/project_workflow_service.py`
quoted six of them, and two were already wrong before the migration — one named
a button that exists nowhere in the codebase. Interpolate the widget's own
msgid instead:

```python
_(" - Open the '{tab}' tab").format(tab=_("Main Control"))
```

## `_` is the gettext alias, never a discard

Binding `_` anywhere in a function shadows the alias for that **whole function**,
including calls that appear *above* the assignment:

```python
success, _ = self.detector_service.initialize_detector(...) # breaks _() above
success, _detector = self.detector_service.initialize_detector(...) # correct
```

`for _ in range(3)`, `h, w, _ = frame.shape`, `for _, name, *_ in items` and
`lambda *_:` all do it. Seven of these turned up during the migration; every one
was caught by `ruff` (F823/F402) or `mypy` (`Name "_" is used before definition`,
`"int" not callable`) and none by reading the diff. Run both, not just `pytest`.

## Reusing an existing msgid

Before adding a pair, check whether the msgid already exists in
`src/zebtrack/locales/_pairs/*.json`:

- **Same Portuguese** ⇒ reuse it and omit it from your batch file.
- **Different Portuguese** ⇒ pick a different, natural English msgid.

Writing a conflicting pair makes `update_translations.py` print `conflicting
translations across batches` and **drop yours** — easy to miss in a long run.
Reuse is also a design signal: when `ui/gui.py` was migrated, 10 of its 16
msgids already existed, which is how it became clear the file kept its own copies
of dialogs `dialog_manager` already owned. Prefer one msgid over two spellings of
the same message — `analysis_control_view_model.py` and
`project_workflow_service.py` had two different wordings of one validation, so
the advice the operator read depended on which path tripped it.

## Adding a language

1. Add it to `SUPPORTED_LANGUAGES` in `src/zebtrack/i18n.py` and to the
Expand All @@ -139,8 +269,10 @@ fails by silently taking the wrong branch.

## The migration

The UI is being converted from hardcoded Portuguese in stages. Two tools support
it:
The UI was converted from hardcoded Portuguese in stages; that work is done and
`i18n_scan.py` reports zero. What follows is kept because the same tooling and
the same traps apply to any future package, and because the ratchet's guarantee
is narrower than it looks. Two tools support it:

- `scripts/i18n_scan.py` lists Portuguese literals that still need extracting
(`--format=count` for a per-file tally).
Expand All @@ -150,6 +282,34 @@ it:
migration is a move, not a translation job — and a reviewer can check that the
`.po` diff is the exact inverse of the source diff.

`tests/i18n/test_no_untranslated_literals.py` holds the list of packages already
migrated and fails if one of them gains a new Portuguese literal. Extend that
list in the same PR that migrates a package.
`tests/i18n/test_no_untranslated_literals.py` fails if a new Portuguese literal
appears. Its list is now just `("src/zebtrack",)`, so files added from here on
are covered the moment they exist.

**The scanner's count is a floor, not a ceiling — and so is the ratchet's.**
Both detect Portuguese by its accented characters, so unaccented Portuguese —
`Salvar projeto`, `Nenhum video`, `Remover`, `dias`, `grupos` — is invisible to
them. In practice each batch turned up between a third and half again as many
literals as the scan reported. Worse, being listed in `MIGRATED_PATHS` proves
nothing about unaccented text: `coordinators/` was published as "done" while
still showing `Aguardando sinal externo... (porta N)`, and `core/recording/`
pushed four unaccented status strings into the live preview label for four
batches after being locked. Read the whole file, and grep for `text=`, `label=`,
`title=`, `show_*`, `set_status` and `add_command` to catch the rest.

Per batch:

1. Read the files; scan, then read for unaccented Portuguese too.
2. Decide stored-vs-rendered for each literal (see the rule above).
3. Check `_pairs/` for msgids you can reuse; write the rest to
`_pairs/<batch>.json` with the original Portuguese as the value.
4. `python scripts/update_translations.py --domain zebtrack` until it reports
`All catalogues are complete`.
5. Migrate the matching test assertions in the same commit, and add the path to
`MIGRATED_PATHS`.
6. `ruff check .`, `mypy .`, `pytest -q`, **and `pytest -m gui -n0`** — the GUI
marker does not run in the fast suite, and one batch had four failures that
only that run caught.

Portuguese comments and docstrings in migrated files are deliberately left as
they are, so the `.po` diff stays the exact inverse of the source diff.
Loading
Loading