From c5f8196dc4c7b8dd764e648ab73e2aecf3bea66d Mon Sep 17 00:00:00 2001 From: Julian Maurice Date: Fri, 4 Apr 2025 13:44:27 +0200 Subject: [PATCH] Bug 39564: Enable runtime translations for plugins (Koha::I18N) This solution uses gettext's textdomain feature to allow plugins to use Koha::I18N for their own translations. Basically we have one textdomain per plugin and we bind the directory where a plugin store its translations (.mo files) to its textdomain. Plugins have to specify the textdomain like this: __d('MyPlugin', 'string to translate') or, if within a template: [% I18N.td('MyPlugin', 'string to translate') %] This patches adds several functions to Koha::I18N: __d, __dn, __dp, __dnp, __dx, __dnx, __dpx, and __dnpx and their template equivalents in Koha::Template::Plugin::I18N It also moves some code for string extraction from gulpfile.js to misc/translator/xgettext-perl and misc/translator/xgettext-js for easier use by plugins authors. (Note that, even if there is a misc/translator/xgettext-js script, this patch does not add support for JS translations in plugins) By default a plugin named Koha::Plugin::Foo will have a textdomain 'Koha-Plugin-Foo'. It can be changed by overriding method locale_textdomain. By default the directory bound to the textdomain will be Koha/Plugin/Foo/locale. It can be changed by overriding method locale_dir. This directory should contain one subdirectory for each language, and each language subdirectory should contain an LC_MESSAGES subdirectory which should contain a .mo file. It should look like this: Koha/ Plugin/ Foo/ locale/ fr_FR/ LC_MESSAGES/ Koha-Plugin-Foo.mo de_DE/ LC_MESSAGES/ Koha-Plugin-Foo.mo ... --- Koha/I18N.pm | 122 ++++++++++++++++++++++++++++++++++ Koha/Plugins/Base.pm | 29 ++++++++ Koha/Template/Plugin/I18N.pm | 115 +++++++++++++++++++++++++++++++- gulpfile.js | 12 +--- misc/translator/xgettext-js | 19 ++++++ misc/translator/xgettext-perl | 17 +++++ misc/translator/xgettext-tt2 | 17 +++++ 7 files changed, 320 insertions(+), 11 deletions(-) create mode 100755 misc/translator/xgettext-js create mode 100755 misc/translator/xgettext-perl diff --git a/Koha/I18N.pm b/Koha/I18N.pm index c957e9df40..4c775a675b 100644 --- a/Koha/I18N.pm +++ b/Koha/I18N.pm @@ -26,6 +26,10 @@ use Encode; use List::Util qw( first ); use Locale::Messages qw( bindtextdomain + dgettext + dngettext + dnpgettext + dpgettext gettext LC_MESSAGES ngettext @@ -34,7 +38,9 @@ use Locale::Messages qw( textdomain ); use POSIX qw(); +use Try::Tiny; use Koha::Cache::Memory::Lite; +use Koha::Plugins; use parent 'Exporter'; our @EXPORT = qw( @@ -47,10 +53,25 @@ our @EXPORT = qw( __px __np __npx + + __d + __dn + __dp + __dnp + __dx + __dnx + __dpx + __dnpx + N__ N__n N__p N__np + + N__d + N__dn + N__dp + N__dnp ); our $textdomain = 'Koha'; @@ -84,6 +105,18 @@ sub init { my $directory = _base_directory(); textdomain($textdomain); bindtextdomain( $textdomain, $directory ); + + my @plugins = Koha::Plugins->get_enabled_plugins(); + foreach my $plugin (@plugins) { + try { + my $locale_dir = $plugin->locale_dir; + if ( $locale_dir && -d $locale_dir && -r $locale_dir ) { + bindtextdomain($plugin->locale_textdomain, $locale_dir); + } + } catch { + warn sprintf('Failed to initialize translations for plugin %s: %s', ref $plugin, $_); + } + } } else { warn "No locale installed. Localization cannot work and is therefore disabled"; } @@ -168,6 +201,79 @@ sub __npx { return _gettext( \&npgettext, [ $msgctxt, $msgid, $msgid_plural, $count ], %vars ); } +sub __d { + my ( $textdomain, $msgid ) = @_; + + $msgid = Encode::encode_utf8($msgid); + + return _gettext( \&dgettext, [ $textdomain, $msgid ] ); +} + +sub __dn { + my ( $textdomain, $msgid, $msgid_plural, $count ) = @_; + + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext( \&dngettext, [ $textdomain, $msgid, $msgid_plural, $count ] ); +} + +sub __dp { + my ( $textdomain, $msgctxt, $msgid ) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + + return _gettext( \&dpgettext, [ $textdomain, $msgctxt, $msgid ] ); +} + +sub __dnp { + my ( $textdomain, $msgctxt, $msgid, $msgid_plural, $count ) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext( \&dnpgettext, [ $textdomain, $msgctxt, $msgid, $msgid_plural, $count ] ); +} + +sub __dx { + my ( $textdomain, $msgid, %vars ) = @_; + + $msgid = Encode::encode_utf8($msgid); + + return _gettext( \&dgettext, [ $textdomain, $msgid ], %vars ); +} + +sub __dnx { + my ( $textdomain, $msgid, $msgid_plural, $count, %vars ) = @_; + + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext( \&dngettext, [ $textdomain, $msgid, $msgid_plural, $count ], %vars ); +} + +sub __dpx { + my ( $textdomain, $msgctxt, $msgid, %vars ) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + + return _gettext( \&dpgettext, [ $textdomain, $msgctxt, $msgid ], %vars ); +} + +sub __dnpx { + my ( $textdomain, $msgctxt, $msgid, $msgid_plural, $count, %vars ) = @_; + + $msgctxt = Encode::encode_utf8($msgctxt); + $msgid = Encode::encode_utf8($msgid); + $msgid_plural = Encode::encode_utf8($msgid_plural); + + return _gettext( \&dnpgettext, [ $textdomain, $msgctxt, $msgid, $msgid_plural, $count ], %vars ); +} + + sub N__ { return $_[0]; } @@ -184,6 +290,22 @@ sub N__np { return $_[1]; } +sub N__d { + return $_[1]; +} + +sub N__dn { + return $_[1]; +} + +sub N__dp { + return $_[2]; +} + +sub N__dnp { + return $_[2]; +} + sub _base_directory { # Directory structure is not the same for dev and standard installs diff --git a/Koha/Plugins/Base.pm b/Koha/Plugins/Base.pm index 75c628e53d..585ce96f8e 100644 --- a/Koha/Plugins/Base.pm +++ b/Koha/Plugins/Base.pm @@ -273,6 +273,35 @@ sub bundle_path { return $self->{_bundle_path}; } +=head2 locale_dir + +Returns the directory where locale files (.mo) are stored + +=cut + +sub locale_dir { + my ($self) = @_; + + return $self->mbf_exists('locale') ? $self->mbf_path('locale') : undef; +} + +=head2 locale_dir + +Returns the locale textdomain for this plugin. The textdomain needs to be +passed to functions __d, __dn, __dp, __dnp, __dx, __dnx, __dpx, __dnpx. +It is also the name of the .mo file that should be present in locale_dir + +By default it's the plugin name with '::' replaced by '-', so for a plugin +named Koha::Plugin::Foo, the text domain will be Koha-Plugin-Foo + +=cut + +sub locale_textdomain { + my ($self) = @_; + + return ( ref $self ) =~ s/::/-/gr; +} + =head2 output $self->output( $data, $content_type[, $status[, $extra_options]]); diff --git a/Koha/Template/Plugin/I18N.pm b/Koha/Template/Plugin/I18N.pm index 9ba622ebcf..16c5f86807 100644 --- a/Koha/Template/Plugin/I18N.pm +++ b/Koha/Template/Plugin/I18N.pm @@ -22,7 +22,7 @@ use Modern::Perl; use base qw( Template::Plugin ); use C4::Context; -use Koha::I18N qw( __ __n __np __npx __nx __p __px __x __xn ); +use Koha::I18N qw( __ __n __np __npx __nx __p __px __x __xn __d __dn __dp __dnp __dx __dnx __dpx __dnpx); =head1 NAME @@ -42,6 +42,15 @@ Koha::Template::Plugin::I18N - Translate strings in templates [% I18N.tnp('bibliographic material', "item", "items", count) %] [% I18N.tnpx('bibliographic material', "{count} item", "{count} items", count, { count = count }) %] + [%# For plugins %] + [% I18N.td("MyPlugin", "Hello!") %] + [% I18N.tdx("MyPlugin", "Hello {name}", { name = name }) %] + [% I18N.tdn("MyPlugin", "Hello friend", "Hello friends", count) %] + [% I18N.tdnx("MyPlugin", "Hello my {count} friend", "Hello my {count} friends", count, { count = count }) %] + [% I18N.tdp("MyPlugin", 'verb', 'Item') # to order %] + [% I18N.tdnp("MyPlugin", 'bibliographic material', "item", "items", count) %] + [% I18N.tdnpx("MyPlugin", 'bibliographic material', "{count} item", "{count} items", count, { count = count }) %] + Do not use this plugin directly. Add the following directive [% PROCESS 'i18n.inc' %] @@ -183,4 +192,108 @@ sub tnpx { return __npx( $msgctxt, $msgid, $msgid_plural, $count, %$vars ); } +=head2 td + + [% I18N.td("TextDomain", "hello") %] + +Same as t, but with textdomain as first argument. Useful for plugins + +=cut + +sub td { + my ($self, $textdomain, $msgid) = @_; + return __d($textdomain, $msgid); +} + +=head2 tdn + + [% I18N.tdn("TextDomain", "item", "items", count) %] + +Same as tn, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdn { + my ( $self, $textdomain, $msgid, $msgid_plural, $count ) = @_; + return __dn( $textdomain, $msgid, $msgid_plural, $count ); +} + +=head2 tdp + + [% I18N.tdp("TextDomain", "context", "hello") %] + +Same as tp, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdp { + my ( $self, $textdomain, $msgctxt, $msgid ) = @_; + return __p( $textdomain, $msgctxt, $msgid ); +} + +=head2 tdnp + + [% I18N.tdnp("TextDomain", "context", "item", "items", count) %] + +Same as tnp, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdnp { + my ( $self, $textdomain, $msgctxt, $msgid, $msgid_plural, $count ) = @_; + return __np( $textdomain, $msgctxt, $msgid, $msgid_plural, $count ); +} + +=head2 tdx + + [% I18N.tdx("TextDomain", "hello {name}", { name = name }) %] + +Same as tx, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdx { + my ($self, $textdomain, $msgid, $vars) = @_; + return __dx($textdomain, $msgid, %$vars); +} + +=head2 tdnx + + [% I18N.tdnx("TextDomain", "{count} item", "{count} items", count, { count = count }) %] + +Same as tnx, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdnx { + my ( $self, $textdomain, $msgid, $msgid_plural, $count, $vars ) = @_; + return __dnx( $textdomain, $msgid, $msgid_plural, $count, %$vars ); +} + +=head2 tdpx + + [% I18N.tdpx("TextDomain", "context", "hello {name}", { name = name }) %] + +Same as tpx, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdpx { + my ( $self, $textdomain, $msgctxt, $msgid, $vars ) = @_; + return __p( $textdomain, $msgctxt, $msgid, %$vars ); +} + +=head2 tdnpx + + [% I18N.tdnpx("TextDomain", "context", "{count} item", "{count} items", count, { count = count }) %] + +Same as tnpx, but with textdomain as first argument. Useful for plugins + +=cut + +sub tdnpx { + my ( $self, $textdomain, $msgctxt, $msgid, $msgid_plural, $count, $vars ) = @_; + return __npx( $textdomain, $msgctxt, $msgid, $msgid_plural, $count, %$vars ); +} + 1; diff --git a/gulpfile.js b/gulpfile.js index 46375e5838..b56ed7a8b4 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -235,14 +235,6 @@ function po_extract_opac() { .pipe(dest("misc/translator")); } -const xgettext_options = - "--from-code=UTF-8 --package-name Koha " + - "--package-version= -k -k__ -k__x -k__n:1,2 -k__nx:1,2 -k__xn:1,2 " + - "-k__p:1c,2 -k__px:1c,2 -k__np:1c,2,3 -k__npx:1c,2,3 -kN__ " + - "-kN__n:1,2 -kN__p:1c,2 -kN__np:1c,2,3 " + - "-k -k$__ -k$__x -k$__n:1,2 -k$__nx:1,2 -k$__xn:1,2 " + - "--force-po"; - function po_extract_messages_js() { const globs = [ "koha-tmpl/intranet-tmpl/prog/js/vue/**/*.vue", @@ -253,7 +245,7 @@ function po_extract_messages_js() { return src(globs, { read: false, nocase: true }) .pipe( xgettext( - `xgettext -L JavaScript ${xgettext_options}`, + `misc/translator/xgettext-js`, "Koha-messages-js.pot" ) ) @@ -264,7 +256,7 @@ function po_extract_messages() { const perlStream = src(["**/*.pl", "**/*.pm"], { read: false, nocase: true, - }).pipe(xgettext(`xgettext -L Perl ${xgettext_options}`, "Koha-perl.pot")); + }).pipe(xgettext('misc/translator/xgettext-perl', "Koha-perl.pot")); const ttStream = src( [ diff --git a/misc/translator/xgettext-js b/misc/translator/xgettext-js new file mode 100755 index 0000000000..32e00cc3fe --- /dev/null +++ b/misc/translator/xgettext-js @@ -0,0 +1,19 @@ +#!/bin/sh + +exec xgettext \ + -L JavaScript \ + --from-code=UTF-8 \ + --package-name Koha \ + --package-version= \ + --force-po \ + -k \ + -k'__' -k'__n:1,2' -k'__p:1c,2' -k'__np:1c,2,3' \ + -k'__x' -k'__nx:1,2' -k'__px:1c,2' -k'__npx:1c,2,3' \ + -k'__xn:1,2' \ + -k'__d:2' -k'__dn:2,3' -k'__dp:2c,3' -k'__dnp:2c,3,4' \ + -k'__dx:2' -k'__dnx:2,3' -k'__dpx:2c,3' -k'__dnpx:2c,3,4' \ + -k'$__' -k'$__n:1,2' -k'$__p:1c,2' -k'$__np:1c,2,3' \ + -k'$__d:2' -k'$__dn:2,3' -k'$__dp:2c,3' -k'$__dnp:2c,3,4' \ + -k'N__' -k'N__n:1,2' -k'N__p:1c,2' -k'N__np:1c,2,3' \ + -k'N__d:2' -k'N__dn:2,3' -k'N__dp:2c,3' -k'N__dnp:2c,3,4' \ + "$@" diff --git a/misc/translator/xgettext-perl b/misc/translator/xgettext-perl new file mode 100755 index 0000000000..1b23af1cfc --- /dev/null +++ b/misc/translator/xgettext-perl @@ -0,0 +1,17 @@ +#!/bin/sh + +exec xgettext \ + -L Perl \ + --from-code=UTF-8 \ + --package-name Koha \ + --package-version= \ + --force-po \ + -k \ + -k'__' -k'__n:1,2' -k'__p:1c,2' -k'__np:1c,2,3' \ + -k'__x' -k'__nx:1,2' -k'__px:1c,2' -k'__npx:1c,2,3' \ + -k'__xn:1,2' \ + -k'__d:2' -k'__dn:2,3' -k'__dp:2c,3' -k'__dnp:2c,3,4' \ + -k'__dx:2' -k'__dnx:2,3' -k'__dpx:2c,3' -k'__dnpx:2c,3,4' \ + -k'N__' -k'N__n:1,2' -k'N__p:1c,2' -k'N__np:1c,2,3' \ + -k'N__d:2' -k'N__dn:2,3' -k'N__dp:2c,3' -k'N__dnp:2c,3,4' \ + "$@" diff --git a/misc/translator/xgettext-tt2 b/misc/translator/xgettext-tt2 index 02bb61f4a1..4b73d24015 100755 --- a/misc/translator/xgettext-tt2 +++ b/misc/translator/xgettext-tt2 @@ -37,6 +37,15 @@ sub defaultKeywords { 'tpx:1c,2', 'tnp:1c,2,3', 'tnpx:1c,2,3', + 'dt:2', + 'dtx:2', + 'dtn:2,3', + 'dtnx:2,3', + 'dtxn:2,3', + 'dtp:2c,3', + 'dtpx:2c,3', + 'dtnp:2c,3,4', + 'dtnpx:2c,3,4', ]; } @@ -50,6 +59,14 @@ sub defaultFlags { 'tpx:2:perl-brace-format', 'tnpx:2:perl-brace-format', 'tnpx:3:perl-brace-format', + 'dtx:2:perl-brace-format', + 'dtnx:2:perl-brace-format', + 'dtnx:3:perl-brace-format', + 'dtxn:2:perl-brace-format', + 'dtxn:3:perl-brace-format', + 'dtpx:3:perl-brace-format', + 'dtnpx:3:perl-brace-format', + 'dtnpx:4:perl-brace-format', ], } -- 2.39.5