Sentences with a count have no plural forms: \"Added 1 entries.\", \"1 copies sent.\", \"3 application kept\ #391
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
performance
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Postulo/postulo#391
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
mainat0a5cad587: still present. The code is now atcore/server_views.py:308and:898,plugins/views.py:382and:429, andcore/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 ablocktranslatewithoutcount. 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 onemsgstrholds 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
_()documents/views.py:240inCVAddItemsView.post(_("Added %(count)s entries."));documents/views.py:720and:726inSendCopiesNowView.post("%(sent)d copies sent, %(failed)d failed; each document says which."and"%(sent)d copies sent."). The same file usesngettextcorrectly in the upload delete confirmation (documents/views.py:633-647).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.") inLinkCheckView.post;resume/views.py:485-491inEuropassImportView._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 usesngettextfor the same sentence (resume/views_candidate.py:138-147).applications/quiet.py:122in_announcement(_("%(role)s at %(company)s — %(days)s days")). Its twin injobs/closing.py:116-136counts the same way withngettext("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:103has"Captured %(count)s postings from %(host)s".core/gdpr.py:203-207inErasureReport.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:213and: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 usesngettext.core/server_views.py:307("… its records and %(files)s files on disk.");core/server_views.py:897andplugins/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:189and inresume/candidate.py:863).quiet_after_daysset 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.2. Templates:
blocktranslatewithoutcounttemplates/widgets/gone_quiet.html:64and:71, andtemplates/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).Why it matters and the fix
ngettextwith the count as the selector and substituted in both forms;ngettext_lazywhere the string is built at import time (ASKED_TOO_OFTEN).{% blocktranslate count … %}…{% plural %}…{% endblocktranslate %}.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.ErasureReport.summary, onengettextpair per kind ("%(count)d telephone number"/"%(count)d telephone numbers", and likewise for postal addresses, web links and plugin rows) replacing_kinds, andngettextfor the two "application kept" sentences. The dry-run line can reuse the same phrases ifretention_dry_runbuilds it.quiet.pyandclosing.pyshare (_owner_of, the firstNAMED_IN_ANNOUNCEMENTlines, "and N more" as anngettext) into one helper both call, so that the next correction lands in both.scripts/messages.py extractand update the catalogues.Done when
tests/test_translations.pyhas a check that fails on any non-plural msgid in thedjangocatalogue 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.pyrejects ablocktranslatethat binds a count (days,rowsand the like) withwithand nocount; the seven blocks above are converted so that it passes.tests/test_documents.pyasserts "Added 1 entry." after adding one entry throughdocuments:cv_add_items, andtests/test_stores.pyasserts "1 copy sent." after Send to stores now with one store connected.tests/test_importers.pyasserts the singular sentence after a Europass import that adds one entry, andtests/test_links_and_letters.pythe singular after checking one link.tests/test_quiet.pyasserts that an application quiet for one day is announced as "1 day".tests/test_gdpr.pyasserts 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.