public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Kefu Chai <k.chai@proxmox.com>
To: Maximiliano Sandoval <m.sandoval@proxmox.com>,
	Thomas Lamprecht <t.lamprecht@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: Sat, 22 Aug 2026 13:22:21 +0000	[thread overview]
Message-ID: <mt4eput5.31elr0dmbelmo@proxmox.com> (raw)
In-Reply-To: <s8otsphc3be.fsf@toolbox>

Hi Thomas,
could you please help review this patch?
On Wednesday, 07/29/2026, 21:50, Maximiliano Sandoval <m.sandoval@proxmox.com> wrote:

From : Maximiliano Sandoval <m.sandoval@proxmox.com>
Sent on : Wednesday, 07/29/2026, 21:50
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
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 [http://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 [http://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 [http://po2js.pl/] b/po2js.pl [http://po2js.pl/]
 index 316c0bd..4b7b044 100755
 --- a/po2js.pl [http://po2js.pl/]
 +++ b/po2js.pl [http://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


      reply	other threads:[~2026-08-22 13:22 UTC|newest]

Thread overview: 4+ 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
2026-08-22 13:22     ` Kefu Chai [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=mt4eput5.31elr0dmbelmo@proxmox.com \
    --to=k.chai@proxmox.com \
    --cc=m.sandoval@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    --cc=t.lamprecht@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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal