From: Maximiliano Sandoval <m.sandoval@proxmox.com>
To: Kefu Chai <k.chai@proxmox.com>
Cc: pve-devel@lists.proxmox.com
Subject: Re: [PATCH v4 i18n 1/1] add pgettext() and npgettext() support for context-aware translations
Date: Wed, 29 Jul 2026 15:50:45 +0200 [thread overview]
Message-ID: <s8otsphc3be.fsf@toolbox> (raw)
In-Reply-To: <20260610075947.1173141-2-k.chai@proxmox.com> (Kefu Chai's message of "Wed, 10 Jun 2026 15:59:47 +0800")
Kefu Chai <k.chai@proxmox.com> writes:
> Context-aware translations (msgctxt in gettext) distinguish identical
> msgids that mean different things in different parts of the UI, for
> example "Monitor" for Ceph versus QEMU.
>
> To keep msgid+msgctxt pairs distinct in the generated JS catalog,
> po2js.pl now hashes the two together with the standard gettext EOT
> separator ("\x04") between them. Messages without a context fall
> through to the existing fnv31a(msgid). Locale::PO::load_file_ashash()
> conflates rows that share the same msgid, so switch to
> load_file_asarray() to preserve the distinct entries. The generated
> catalog gains JS pgettext() and npgettext() helpers that perform the
> same lookup at runtime.
>
> The Makefile change adds --keyword=npgettext:1c,2,3 to xgettext.
> pgettext is in xgettext's default JavaScript keyword list, but
> npgettext is not: it was omitted from the defaults in gettext 0.18.3
> (2013) when JavaScript support first landed, and never added since.
> Without the explicit flag, xgettext silently drops npgettext() calls
> during 'make update_pot'.
>
> See "xgettext Invocation" in the GNU gettext manual:
> https://www.gnu.org/software/gettext/manual/html_node/xgettext-Invocation.html
>
> Signed-off-by: Kefu Chai <k.chai@proxmox.com>
> ---
> Makefile | 1 +
> po2js.pl | 69 ++++++++++++++++++++++++++++++++++++++++++++++++--------
> 2 files changed, 61 insertions(+), 9 deletions(-)
>
> diff --git a/Makefile b/Makefile
> index 16aab32..e1d9633 100644
> --- a/Makefile
> +++ b/Makefile
> @@ -155,6 +155,7 @@ define potupdate
> --package-version="$(shell cd $(2);git rev-parse HEAD)" \
> --msgid-bugs-address="<support@proxmox.com>" \
> --copyright-holder="Copyright (C) Proxmox Server Solutions GmbH <support@proxmox.com> & the translation contributors." \
> + --keyword=npgettext:1c,2,3 \
> --output="$(1)".pot
> endef
>
> diff --git a/po2js.pl b/po2js.pl
> index 316c0bd..4b7b044 100755
> --- a/po2js.pl
> +++ b/po2js.pl
> @@ -8,10 +8,6 @@ use Getopt::Long;
> use JSON;
> use Locale::PO;
>
> -# current limits:
> -# - we do not support plural. forms
> -# - no message content support
> -
> my $options = {};
> GetOptions($options, 't=s', 'o=s', 'v=s') or die "unable to parse options\n";
>
> @@ -34,6 +30,15 @@ sub fnv31a {
> return $hval & 0x7fffffff;
> }
>
> +# Hash function for messages with context
> +sub fnv31a_ctxt {
> + my ($msgctxt, $msgid) = @_;
> + return fnv31a($msgid) if !length($msgctxt); # Empty context = no context
> + # Use EOT character (0x04) as separator, standard in gettext
> + my $combined = $msgctxt . "\x04" . $msgid;
> + return fnv31a($combined);
> +}
> +
> my $catalog = {};
> my $plurals_catalog = {};
>
> @@ -41,11 +46,20 @@ my $nplurals = 2;
> my $plural_forms = "n!=1";
>
> foreach my $filename (@ARGV) {
> - my $href = Locale::PO->load_file_ashash($filename)
> + my $aref = Locale::PO->load_file_asarray($filename)
> || die "unable to load '$filename'\n";
>
> my $charset;
> - my $hpo = $href->{'""'} || die "no header";
> + # Find header entry (msgid "")
> + my $hpo;
> + foreach my $po (@$aref) {
> + if ($po->msgid eq '""') {
> + $hpo = $po;
> + last;
> + }
> + }
> + die "no header" if !$hpo;
> +
> my $header = $hpo->dequote($hpo->msgstr);
> if ($header =~ m|^Content-Type:\s+text/plain;\s+charset=(\S+)$|im) {
> $charset = $1;
> @@ -58,8 +72,7 @@ foreach my $filename (@ARGV) {
> $plural_forms = $2;
> }
>
> - foreach my $k (keys %$href) {
> - my $po = $href->{$k};
> + foreach my $po (@$aref) {
> next if $po->fuzzy(); # skip fuzzy entries
> my $ref = $po->reference();
>
> @@ -76,9 +89,18 @@ foreach my $filename (@ARGV) {
> my $qmsgid_plural = decode($charset, $po->msgid_plural);
> my $msgid_plural = $po->dequote($qmsgid_plural);
>
> + # Extract message context if present
> + my $msgctxt = '';
> + if (defined($po->msgctxt)) {
> + my $qmsgctxt = decode($charset, $po->msgctxt);
> + $msgctxt = $po->dequote($qmsgctxt);
> + }
> +
> next if !length($msgid) && !length($msgid_plural); # skip header
>
> - my $digest = fnv31a($msgid);
> + my $digest = length($msgctxt) > 0
> + ? fnv31a_ctxt($msgctxt, $msgid)
> + : fnv31a($msgid);
>
> die "duplicate digest" if $catalog->{$digest};
>
> @@ -150,6 +172,35 @@ function ngettext(singular, plural, n) {
> }
> return translation[msg_idx];
> }
> +
> +function fnv31a_ctxt(context, text) {
> + // Use EOT character (0x04) as separator
> + var combined = context + "\\x04" + text;
> + return fnv31a(combined);
> +}
> +
> +function pgettext(context, msgid) {
> + var digest = fnv31a_ctxt(context, msgid);
> + var data = __proxmox_i18n_msgcat__[digest];
> + if (!data) {
> + return msgid; // Return msgid (not context) as fallback
> + }
> + return data[0] || msgid;
> +}
> +
> +function npgettext(context, singular, plural, n) {
> + const msg_idx = Number($plural_forms);
> + const digest = fnv31a_ctxt(context, singular);
> + const translation = __proxmox_i18n_plurals_msgcat__[digest];
> + if (!translation || msg_idx >= translation.length) {
> + if (n === 1) {
> + return singular;
> + } else {
> + return plural;
> + }
> + }
> + return translation[msg_idx];
> +}
> __EOD
>
> if ($outfile) {
Tested that strings using pgettext are correctly extracted:
#: pve-manager/www/manager6/ceph/Monitor.js:36
#: pve-manager/www/manager6/node/Config.js:373
msgctxt "ceph"
msgid "Monitor"
msgstr ""
#: pve-manager/www/manager6/qemu/Config.js:350
msgctxt "qemu-guest-agent"
msgid "Monitor"
msgstr ""
and that they are displayed correctly on the web UI after translation.
This would be a good addition for a couple of strings where we have two
versions of the same string which might require different translations.
lgtm.
Tested-by: Maximiliano Sandoval <m.sandoval@proxmox.com>
Reviewed-by: Maximiliano Sandoval <m.sandoval@proxmox.com>
--
Maximiliano
prev parent reply other threads:[~2026-07-29 13:51 UTC|newest]
Thread overview: 3+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-10 7:59 [PATCH v4 i18n 0/1] add pgettext() and npgettext() support for context-aware translations Kefu Chai
2026-06-10 7:59 ` [PATCH v4 i18n 1/1] " Kefu Chai
2026-07-29 13:50 ` Maximiliano Sandoval [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=s8otsphc3be.fsf@toolbox \
--to=m.sandoval@proxmox.com \
--cc=k.chai@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox