Sentences with a count have no plural forms: \"Added 1 entries.\", \"1 copies sent.\", \"3 application kept\ #391

Closed
opened 2026-10-01 10:46:21 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading every translatable sentence that puts a variable number beside a noun.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at core/server_views.py:308 and :898, plugins/views.py:382 and :429, and core/phone_numbers.py:143-146 (ASKED_TOO_OFTEN, formatted at :166); every other location below is unchanged.

About twenty-five messages put a count beside a noun inside a single-form gettext, or inside a blocktranslate without count. English reads wrongly at one ("Added 1 entries.", "1 copies sent.", "All 1 links still answer.", "Try again in 1 seconds.") or at every other number ("3 application kept"). The catalogues with three or more plural forms that Postulo ships (Polish, Czech, Slovak, Slovene, Croatian, Serbian, Bosnian, Ukrainian, Lithuanian, Latvian, Irish, Maltese, Welsh, Arabic) cannot be made right at all, because one msgstr holds one form: the Polish catalogue has "Dodano %(count)s wpisów." for every count. Translators cannot repair this from Weblate.

Related to #225 (closed), which converted the browser's "N rows ticked" and the appearance page's "days", and to #250 (closed), which corrected catalogue entries that already had plural forms. Neither reached the messages below, and the erasure sentences arrived after them with #297. The bulk message in core/bulk.py:113, which chooses its noun by hand, is left to its own issue.

1. Python messages in single-form _()

  • Where:
    • Documents: documents/views.py:240 in CVAddItemsView.post (_("Added %(count)s entries.")); documents/views.py:720 and :726 in SendCopiesNowView.post ("%(sent)d copies sent, %(failed)d failed; each document says which." and "%(sent)d copies sent."). The same file uses ngettext correctly in the upload delete confirmation (documents/views.py:633-647).
    • Your career: resume/views.py:386 ("%(ok)d answered, %(broken)d did not; the ones that did not say why.") and :390 ("All %(ok)d links still answer.") in LinkCheckView.post; resume/views.py:485-491 in EuropassImportView._write ("Added %(total)s entries. Nothing was overwritten; …"); resume/candidate.py:660 ("… has %(total)s rows in the file. The first %(limit)s are read, …"). The candidate-file view beside them already uses ngettext for the same sentence (resume/views_candidate.py:138-147).
    • Announcements: applications/quiet.py:122 in _announcement (_("%(role)s at %(company)s — %(days)s days")). Its twin in jobs/closing.py:116-136 counts the same way with ngettext("in %(days)s day", "in %(days)s days", days), and the dashboard widget shows the same fact with {% blocktranslate … count days=… %} (templates/widgets/gone_quiet.html:28), so the copies have already drifted. Both builders end with _("and %(more)s more") (applications/quiet.py:131, jobs/closing.py:136). notifications/slow.py:103 has "Captured %(count)s postings from %(host)s".
    • Erasure: core/gdpr.py:203-207 in ErasureReport.summary (_("%(count)d %(kind)s"), the kind taken from _kinds, core/gdpr.py:240-247, whose words are always plural: "telephone numbers", "postal addresses", "web links", "plugin rows"); core/gdpr.py:213 and :218 ("%(count)d application kept, without its main contact." and "… without its referrer.", singular for every count). The listings-history sentence in the same method (core/gdpr.py:224-229) already uses ngettext.
    • Server settings and connections: core/server_views.py:307 ("… its records and %(files)s files on disk."); core/server_views.py:897 and plugins/views.py:381 ("That is a lot of tests. Try again in %(seconds)d seconds."); plugins/views.py:428 ("%(count)d documents are queued for %(label)s. …"); core/phone_numbers.py:166-169 (ASKED_TOO_OFTEN, "… available again in %(minutes)d minutes.", a module-level lazy string formatted at :189 and in resume/candidate.py:863).
  • What happens:
    • Adding one entry to a CV says "Added 1 entries."; Send to stores now with one store connected says "1 copies sent."; importing a Europass file that adds one skill says "Added 1 entries. …"; Check them all on a record with one link says "All 1 links still answer."
    • With quiet_after_days set to 1 (allowed: 1 to 365, accounts/models.py:400-403), the gone-quiet notification reads "Tester at Acme — 1 days". At 21, 22 and 25 a Ukrainian reader needs день, дні and днів; the catalogue can give one.
    • Erasing a contact with one telephone number who was the main contact on two applications shows "Ada is gone, with 1 telephone numbers. 2 application kept, without its main contact."

2. Templates: blocktranslate without count

  • Where:
    • templates/widgets/gone_quiet.html:64 and :71, and templates/widgets/sources.html:42: {{ days }} days, the person's quiet threshold.
    • templates/core/import_csv_map.html:10 ({{ rows }} rows) and :109 (three counts in one sentence: applications, listings, rows skipped).
    • templates/server/data_protection.html:48 ({{ days }} days) and :61-63 (four counts in one sentence: {{ n }} telephone numbers, {{ a }} addresses, {{ l }} web links; {{ apps }} application kept).
  • What happens: the retention dry run lists each contact as "1 telephone numbers, 0 addresses, 1 web links; 3 application kept"; a one-row spreadsheet is announced as "1 rows"; a threshold of one day reads "nothing for 1 days".

Why it matters and the fix

  • Why it matters: the promise is that every user-facing string is translatable. These are the messages people read after everyday actions, and the sentences that say what an irreversible erasure did or would do; they are wrong in English at one and cannot be written correctly in most Slavic, Baltic and Celtic catalogues for most numbers.
  • Fix:
    • In Python, ngettext with the count as the selector and substituted in both forms; ngettext_lazy where the string is built at import time (ASKED_TOO_OFTEN).
    • In templates, {% blocktranslate count … %}…{% plural %}…{% endblocktranslate %}.
    • Where one sentence holds several counts (documents/views.py:720, resume/views.py:386, resume/candidate.py:660, import_csv_map.html:109, data_protection.html:61), give each count its own counted phrase or sentence and join them, rather than choosing one selector for all.
    • In ErasureReport.summary, one ngettext pair per kind ("%(count)d telephone number" / "%(count)d telephone numbers", and likewise for postal addresses, web links and plugin rows) replacing _kinds, and ngettext for the two "application kept" sentences. The dry-run line can reuse the same phrases if retention_dry_run builds it.
    • Move the shape quiet.py and closing.py share (_owner_of, the first NAMED_IN_ANNOUNCEMENT lines, "and N more" as an ngettext) into one helper both call, so that the next correction lands in both.
    • Run scripts/messages.py extract and update the catalogues.

Done when

  • tests/test_translations.py has a check that fails on any non-plural msgid in the django catalogue that carries a count placeholder (%(count), %(days), %(seconds), %(minutes), %(files), %(total), %(sent), %(more)) beside a noun, with an explicit allowlist for sentences where the number is not a count of the noun; it fails today on the strings above.
  • tests/test_template_lint.py rejects a blocktranslate that binds a count (days, rows and the like) with with and no count; the seven blocks above are converted so that it passes.
  • tests/test_documents.py asserts "Added 1 entry." after adding one entry through documents:cv_add_items, and tests/test_stores.py asserts "1 copy sent." after Send to stores now with one store connected.
  • tests/test_importers.py asserts the singular sentence after a Europass import that adds one entry, and tests/test_links_and_letters.py the singular after checking one link.
  • tests/test_quiet.py asserts that an application quiet for one day is announced as "1 day".
  • tests/test_gdpr.py asserts that the summary of an erasure with one web link and one application reads "1 web link" and "1 application kept", and that the data-protection page renders "1 telephone number" for a contact with one.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading every translatable sentence that puts a variable number beside a noun. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `core/server_views.py:308` and `:898`, `plugins/views.py:382` and `:429`, and `core/phone_numbers.py:143-146` (`ASKED_TOO_OFTEN`, formatted at `:166`); every other location below is unchanged. About twenty-five messages put a count beside a noun inside a single-form `gettext`, or inside a `blocktranslate` without `count`. English reads wrongly at one ("Added 1 entries.", "1 copies sent.", "All 1 links still answer.", "Try again in 1 seconds.") or at every other number ("3 application kept"). The catalogues with three or more plural forms that Postulo ships (Polish, Czech, Slovak, Slovene, Croatian, Serbian, Bosnian, Ukrainian, Lithuanian, Latvian, Irish, Maltese, Welsh, Arabic) cannot be made right at all, because one `msgstr` holds one form: the Polish catalogue has "Dodano %(count)s wpisów." for every count. Translators cannot repair this from Weblate. Related to #225 (closed), which converted the browser's "N rows ticked" and the appearance page's "days", and to #250 (closed), which corrected catalogue entries that already had plural forms. Neither reached the messages below, and the erasure sentences arrived after them with #297. The bulk message in `core/bulk.py:113`, which chooses its noun by hand, is left to its own issue. ## 1. Python messages in single-form `_()` - **Where:** - Documents: `documents/views.py:240` in `CVAddItemsView.post` (`_("Added %(count)s entries.")`); `documents/views.py:720` and `:726` in `SendCopiesNowView.post` (`"%(sent)d copies sent, %(failed)d failed; each document says which."` and `"%(sent)d copies sent."`). The same file uses `ngettext` correctly in the upload delete confirmation (`documents/views.py:633-647`). - Your career: `resume/views.py:386` (`"%(ok)d answered, %(broken)d did not; the ones that did not say why."`) and `:390` (`"All %(ok)d links still answer."`) in `LinkCheckView.post`; `resume/views.py:485-491` in `EuropassImportView._write` (`"Added %(total)s entries. Nothing was overwritten; …"`); `resume/candidate.py:660` (`"… has %(total)s rows in the file. The first %(limit)s are read, …"`). The candidate-file view beside them already uses `ngettext` for the same sentence (`resume/views_candidate.py:138-147`). - Announcements: `applications/quiet.py:122` in `_announcement` (`_("%(role)s at %(company)s — %(days)s days")`). Its twin in `jobs/closing.py:116-136` counts the same way with `ngettext("in %(days)s day", "in %(days)s days", days)`, and the dashboard widget shows the same fact with `{% blocktranslate … count days=… %}` (`templates/widgets/gone_quiet.html:28`), so the copies have already drifted. Both builders end with `_("and %(more)s more")` (`applications/quiet.py:131`, `jobs/closing.py:136`). `notifications/slow.py:103` has `"Captured %(count)s postings from %(host)s"`. - Erasure: `core/gdpr.py:203-207` in `ErasureReport.summary` (`_("%(count)d %(kind)s")`, the kind taken from `_kinds`, `core/gdpr.py:240-247`, whose words are always plural: "telephone numbers", "postal addresses", "web links", "plugin rows"); `core/gdpr.py:213` and `:218` (`"%(count)d application kept, without its main contact."` and `"… without its referrer."`, singular for every count). The listings-history sentence in the same method (`core/gdpr.py:224-229`) already uses `ngettext`. - Server settings and connections: `core/server_views.py:307` (`"… its records and %(files)s files on disk."`); `core/server_views.py:897` and `plugins/views.py:381` (`"That is a lot of tests. Try again in %(seconds)d seconds."`); `plugins/views.py:428` (`"%(count)d documents are queued for %(label)s. …"`); `core/phone_numbers.py:166-169` (`ASKED_TOO_OFTEN`, `"… available again in %(minutes)d minutes."`, a module-level lazy string formatted at `:189` and in `resume/candidate.py:863`). - **What happens:** - Adding one entry to a CV says "Added 1 entries."; *Send to stores now* with one store connected says "1 copies sent."; importing a Europass file that adds one skill says "Added 1 entries. …"; *Check them all* on a record with one link says "All 1 links still answer." - With `quiet_after_days` set to 1 (allowed: 1 to 365, `accounts/models.py:400-403`), the gone-quiet notification reads "Tester at Acme — 1 days". At 21, 22 and 25 a Ukrainian reader needs *день*, *дні* and *днів*; the catalogue can give one. - Erasing a contact with one telephone number who was the main contact on two applications shows "Ada is gone, with 1 telephone numbers. 2 application kept, without its main contact." ## 2. Templates: `blocktranslate` without `count` - **Where:** - `templates/widgets/gone_quiet.html:64` and `:71`, and `templates/widgets/sources.html:42`: `{{ days }} days`, the person's quiet threshold. - `templates/core/import_csv_map.html:10` (`{{ rows }} rows`) and `:109` (three counts in one sentence: applications, listings, rows skipped). - `templates/server/data_protection.html:48` (`{{ days }} days`) and `:61-63` (four counts in one sentence: `{{ n }} telephone numbers, {{ a }} addresses, {{ l }} web links; {{ apps }} application kept`). - **What happens:** the retention dry run lists each contact as "1 telephone numbers, 0 addresses, 1 web links; 3 application kept"; a one-row spreadsheet is announced as "1 rows"; a threshold of one day reads "nothing for 1 days". ## Why it matters and the fix - **Why it matters:** the promise is that every user-facing string is translatable. These are the messages people read after everyday actions, and the sentences that say what an irreversible erasure did or would do; they are wrong in English at one and cannot be written correctly in most Slavic, Baltic and Celtic catalogues for most numbers. - **Fix:** - In Python, `ngettext` with the count as the selector and substituted in both forms; `ngettext_lazy` where the string is built at import time (`ASKED_TOO_OFTEN`). - In templates, `{% blocktranslate count … %}…{% plural %}…{% endblocktranslate %}`. - Where one sentence holds several counts (`documents/views.py:720`, `resume/views.py:386`, `resume/candidate.py:660`, `import_csv_map.html:109`, `data_protection.html:61`), give each count its own counted phrase or sentence and join them, rather than choosing one selector for all. - In `ErasureReport.summary`, one `ngettext` pair per kind (`"%(count)d telephone number"` / `"%(count)d telephone numbers"`, and likewise for postal addresses, web links and plugin rows) replacing `_kinds`, and `ngettext` for the two "application kept" sentences. The dry-run line can reuse the same phrases if `retention_dry_run` builds it. - Move the shape `quiet.py` and `closing.py` share (`_owner_of`, the first `NAMED_IN_ANNOUNCEMENT` lines, "and N more" as an `ngettext`) into one helper both call, so that the next correction lands in both. - Run `scripts/messages.py extract` and update the catalogues. ## Done when - `tests/test_translations.py` has a check that fails on any non-plural msgid in the `django` catalogue that carries a count placeholder (`%(count)`, `%(days)`, `%(seconds)`, `%(minutes)`, `%(files)`, `%(total)`, `%(sent)`, `%(more)`) beside a noun, with an explicit allowlist for sentences where the number is not a count of the noun; it fails today on the strings above. - `tests/test_template_lint.py` rejects a `blocktranslate` that binds a count (`days`, `rows` and the like) with `with` and no `count`; the seven blocks above are converted so that it passes. - `tests/test_documents.py` asserts "Added 1 entry." after adding one entry through `documents:cv_add_items`, and `tests/test_stores.py` asserts "1 copy sent." after *Send to stores now* with one store connected. - `tests/test_importers.py` asserts the singular sentence after a Europass import that adds one entry, and `tests/test_links_and_letters.py` the singular after checking one link. - `tests/test_quiet.py` asserts that an application quiet for one day is announced as "1 day". - `tests/test_gdpr.py` asserts that the summary of an erasure with one web link and one application reads "1 web link" and "1 application kept", and that the data-protection page renders "1 telephone number" for a contact with one.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:46:21 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#391
No description provided.