Bug 21395: Make perlcritic happy
[srvgit] / misc / translator / LangInstaller.pm
index c8a204a..b9c5c0d 100644 (file)
@@ -4,31 +4,54 @@ package LangInstaller;
 #
 # This file is part of Koha.
 #
-# Koha is free software; you can redistribute it and/or modify it under the
-# terms of the GNU General Public License as published by the Free Software
-# Foundation; either version 2 of the License, or (at your option) any later
-# version.
+# Koha is free software; you can redistribute it and/or modify it
+# under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 3 of the License, or
+# (at your option) any later version.
 #
-# Koha is distributed in the hope that it will be useful, but WITHOUT ANY
-# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
-# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
+# Koha is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
 #
-# You should have received a copy of the GNU General Public License along
-# with Koha; if not, write to the Free Software Foundation, Inc.,
-# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+# You should have received a copy of the GNU General Public License
+# along with Koha; if not, see <http://www.gnu.org/licenses>.
 
-use strict;
-use warnings;
+use Modern::Perl;
 
 use C4::Context;
 # WARNING: Any other tested YAML library fails to work properly in this
 # script content
-use YAML::Syck qw( Dump LoadFile );
+use YAML::Syck qw( Dump LoadFile DumpFile );
 use Locale::PO;
 use FindBin qw( $Bin );
+use File::Basename;
+use File::Find;
+use File::Path qw( make_path );
+use File::Copy;
+use File::Slurp;
+use File::Spec;
+use File::Temp qw( tempdir tempfile );
+use Template::Parser;
+use PPI;
+
 
 $YAML::Syck::ImplicitTyping = 1;
 
+
+# Default file header for .po syspref files
+my $default_pref_po_header = Locale::PO->new(-msgid => '', -msgstr =>
+    "Project-Id-Version: PACKAGE VERSION\\n" .
+    "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\\n" .
+    "Last-Translator: FULL NAME <EMAIL\@ADDRESS>\\n" .
+    "Language-Team: Koha Translate List <koha-translate\@lists.koha-community.org>\\n" .
+    "MIME-Version: 1.0\\n" .
+    "Content-Type: text/plain; charset=UTF-8\\n" .
+    "Content-Transfer-Encoding: 8bit\\n" .
+    "Plural-Forms: nplurals=2; plural=(n > 1);\\n"
+);
+
+
 sub set_lang {
     my ($self, $lang) = @_;
 
@@ -39,7 +62,7 @@ sub set_lang {
 
 
 sub new {
-    my ($class, $lang, $pref_only) = @_;
+    my ($class, $lang, $pref_only, $verbose) = @_;
 
     my $self                 = { };
 
@@ -49,33 +72,104 @@ sub new {
                                '/prog/en/modules/admin/preferences';
     set_lang( $self, $lang ) if $lang;
     $self->{pref_only}       = $pref_only;
-    $self->{translator_path} = $Bin;
-    $self->{path_po}         = $self->{translator_path} . "/po";
-    $self->{po}              = {};
+    $self->{verbose}         = $verbose;
+    $self->{process}         = "$Bin/tmpl_process3.pl " . ($verbose ? '' : '-q');
+    $self->{path_po}         = "$Bin/po";
+    $self->{po}              = { '' => $default_pref_po_header };
+    $self->{domain}          = 'Koha';
+    $self->{cp}              = `which cp`;
+    $self->{msgmerge}        = `which msgmerge`;
+    $self->{msgfmt}          = `which msgfmt`;
+    $self->{msginit}         = `which msginit`;
+    $self->{msgattrib}       = `which msgattrib`;
+    $self->{xgettext}        = `which xgettext`;
+    $self->{sed}             = `which sed`;
+    $self->{po2json}         = "$Bin/po2json";
+    $self->{gzip}            = `which gzip`;
+    $self->{gunzip}          = `which gunzip`;
+    chomp $self->{cp};
+    chomp $self->{msgmerge};
+    chomp $self->{msgfmt};
+    chomp $self->{msginit};
+    chomp $self->{msgattrib};
+    chomp $self->{xgettext};
+    chomp $self->{sed};
+    chomp $self->{gzip};
+    chomp $self->{gunzip};
+
+    unless ($self->{xgettext}) {
+        die "Missing 'xgettext' executable. Have you installed the gettext package?\n";
+    }
 
     # Get all .pref file names
     opendir my $fh, $self->{path_pref_en};
-    my @pref_files = grep { /.pref/ } readdir($fh);
+    my @pref_files = grep { /\.pref$/ } readdir($fh);
     close $fh;
     $self->{pref_files} = \@pref_files;
 
     # Get all available language codes
     opendir $fh, $self->{path_po};
-    my @langs =  map { ($_) =~ /(.*)-i-opac/ } 
-        grep { $_ =~ /.*-opac-/ } readdir($fh);
+    my @langs =  map { ($_) =~ /(.*)-pref/ }
+        grep { $_ =~ /.*-pref/ } readdir($fh);
     closedir $fh;
     $self->{langs} = \@langs;
 
     # Map for both interfaces opac/intranet
-    $self->{interface} = {
-        opac => {
-            dir    => $context->config('opachtdocs') . '/prog',
-            suffix => '-i-opac-t-prog-v-3006000.po',
-        },
-        intranet => {
+    my $opachtdocs = $context->config('opachtdocs');
+    $self->{interface} = [
+        {
+            name   => 'Intranet prog UI',
             dir    => $context->config('intrahtdocs') . '/prog',
-            suffix => '-i-staff-t-prog-v-3006000.po',
+            suffix => '-staff-prog.po',
+        },
+    ];
+
+    # OPAC themes
+    opendir my $dh, $context->config('opachtdocs');
+    for my $theme ( grep { not /^\.|lib|xslt/ } readdir($dh) ) {
+        push @{$self->{interface}}, {
+            name   => "OPAC $theme",
+            dir    => "$opachtdocs/$theme",
+            suffix => "-opac-$theme.po",
+        };
+    }
+
+    # MARC flavours (hardcoded list)
+    for ( "MARC21", "UNIMARC", "NORMARC" ) {
+        # search for strings on staff & opac marc files
+        my $dirs = $context->config('intrahtdocs') . '/prog';
+        opendir $fh, $context->config('opachtdocs');
+        for ( grep { not /^\.|\.\.|lib$|xslt/ } readdir($fh) ) {
+            $dirs .= ' ' . "$opachtdocs/$_";
         }
+        push @{$self->{interface}}, {
+            name   => "$_",
+            dir    => $dirs,
+            suffix => "-marc-$_.po",
+        };
+    }
+
+    # EN YAML installer files
+    push @{$self->{installer}}, {
+        name   => "YAML installer files",
+        dirs   => [ 'installer/data/mysql/en/mandatory',
+                    'installer/data/mysql/en/optional'],
+        suffix => "-installer.po",
+    };
+
+    # EN MARC21 YAML installer files
+    push @{$self->{installer}}, {
+        name   => "MARC21 YAML installer files",
+        dirs   => [ 'installer/data/mysql/en/marcflavour/marc21/mandatory',
+                    'installer/data/mysql/en/marcflavour/marc21/optional'],
+        suffix => "-installer-MARC21.po",
+    };
+
+    # EN UNIMARC YAML installer files
+    push @{$self->{installer}}, {
+        name   => "UNIMARC YAML installer files",
+        dirs   => [ 'installer/data/mysql/en/marcflavour/unimarc/mandatory', ],
+        suffix => "-installer-UNIMARC.po",
     };
 
     bless $self, $class;
@@ -83,11 +177,12 @@ sub new {
 
 
 sub po_filename {
-    my $self = shift;
+    my $self   = shift;
+    my $suffix = shift;
 
     my $context    = C4::Context->new;
     my $trans_path = $Bin . '/po';
-    my $trans_file = "$trans_path/" . $self->{lang} . "-pref.po";
+    my $trans_file = "$trans_path/" . $self->{lang} . $suffix;
     return $trans_file;
 }
 
@@ -123,7 +218,7 @@ sub add_prefs {
         for my $element ( @$pref ) {
             if ( ref( $element) eq 'HASH' ) {
                 while ( my ($key, $value) = each(%$element) ) {
-                    next unless $key eq 'choices';
+                    next unless $key eq 'choices' or $key eq 'multiple';
                     next unless ref($value) eq 'HASH';
                     for my $ckey ( keys %$value ) {
                         my $id = $self->{file} . "#$pref_name# " . $value->{$ckey};
@@ -131,7 +226,7 @@ sub add_prefs {
                     }
                 }
             }
-            elsif ( $element && $pref_name ) {
+            elsif ( $element ) {
                 $self->po_append( $self->{file} . "#$pref_name# $element", $comment );
             }
         }
@@ -164,7 +259,7 @@ sub update_tab_prefs {
             my $element = $p->[$i];
             if ( ref( $element) eq 'HASH' ) {
                 while ( my ($key, $value) = each(%$element) ) {
-                    next unless $key eq 'choices';
+                    next unless $key eq 'choices' or $key eq 'multiple';
                     next unless ref($value) eq 'HASH';
                     for my $ckey ( keys %$value ) {
                         my $id = $self->{file} . "#$pref_name# " . $value->{$ckey};
@@ -173,7 +268,7 @@ sub update_tab_prefs {
                     }
                 }
             }
-            elsif ( $element && $pref_name ) {
+            elsif ( $element ) {
                 my $id = $self->{file} . "#$pref_name# $element";
                 my $text = $self->get_trans_text( $id );
                 $p->[$i] = $text if $text;
@@ -208,9 +303,14 @@ sub get_po_from_prefs {
 
 sub save_po {
     my $self = shift;
+
+    # Create file header if it doesn't already exist
+    my $po = $self->{po};
+    $po->{''} ||= $default_pref_po_header;
+
     # Write .po entries into a file put in Koha standard po directory
-    Locale::PO->save_file_fromhash( $self->po_filename, $self->{po} );
-    print "Saved in file: ", $self->po_filename, "\n";
+    Locale::PO->save_file_fromhash( $self->po_filename("-pref.po"), $po );
+    say "Saved in file: ", $self->po_filename("-pref.po") if $self->{verbose};
 }
 
 
@@ -222,7 +322,7 @@ sub get_po_merged_with_en {
     my $po_current = $self->{po};
 
     # Get po from previous generation
-    my $po_previous = Locale::PO->load_file_ashash( $self->po_filename );
+    my $po_previous = Locale::PO->load_file_ashash( $self->po_filename("-pref.po") );
 
     for my $id ( keys %$po_current ) {
         my $po =  $po_previous->{Locale::PO->quote($id)};
@@ -236,7 +336,7 @@ sub get_po_merged_with_en {
 sub update_prefs {
     my $self = shift;
     print "Update '", $self->{lang},
-          "' preferences .po file from 'en' .pref files\n";
+          "' preferences .po file from 'en' .pref files\n" if $self->{verbose};
     $self->get_po_merged_with_en();
     $self->save_po();
 }
@@ -276,12 +376,17 @@ sub install_prefs {
                 my $id = $self->{file} . " $section";
                 my $text = $self->get_trans_text($id);
                 my $nsection = $text ? $text : $section;
-                $ntab->{$nsection} = $tab_content->{$section};
+                if( exists $ntab->{$nsection} ) {
+                    # When translations collide (see BZ 18634)
+                    push @{$ntab->{$nsection}}, @{$tab_content->{$section}};
+                } else {
+                    $ntab->{$nsection} = $tab_content->{$section};
+                }
             }
             $pref->{$tab} = $ntab;
         }
         my $file_trans = $self->{po_path_lang} . "/$file";
-        print "Write $file\n";
+        print "Write $file\n" if $self->{verbose};
         open my $fh, ">", $file_trans;
         print $fh Dump($pref);
     }
@@ -289,43 +394,68 @@ sub install_prefs {
 
 
 sub install_tmpl {
-    my $self = shift;
-
-    print
-        "Install templates\n";
-    while ( my ($interface, $tmpl) = each %{$self->{interface}} ) {
-        print
-            "  Install templates '$interface\n",
-            "    From: $tmpl->{dir}/en/\n",
-            "    To  : $tmpl->{dir}/$self->{lang}\n",
-            "    With: $self->{path_po}/$self->{lang}$tmpl->{suffix}\n";
-        my $lang_dir = "$tmpl->{dir}/$self->{lang}";
-        mkdir $lang_dir unless -d $lang_dir;
-        system
-            "$self->{translator_path}/tmpl_process3.pl install " .
-            "-i $tmpl->{dir}/en/ " .
-            "-o $tmpl->{dir}/$self->{lang} ".
-            "-s $self->{path_po}/$self->{lang}$tmpl->{suffix} -r"
+    my ($self, $files) = @_;
+    say "Install templates" if $self->{verbose};
+    for my $trans ( @{$self->{interface}} ) {
+        my @t_dirs = split(" ", $trans->{dir});
+        for my $t_dir ( @t_dirs ) {
+            my @files   = @$files;
+            my @nomarc = ();
+            print
+                "  Install templates '$trans->{name}'\n",
+                "    From: $t_dir/en/\n",
+                "    To  : $t_dir/$self->{lang}\n",
+                "    With: $self->{path_po}/$self->{lang}$trans->{suffix}\n"
+                if $self->{verbose};
+
+            my $trans_dir = "$t_dir/en/";
+            my $lang_dir  = "$t_dir/$self->{lang}";
+            $lang_dir =~ s|/en/|/$self->{lang}/|;
+            mkdir $lang_dir unless -d $lang_dir;
+            # if installing MARC po file, only touch corresponding files
+            my $marc     = ( $trans->{name} =~ /MARC/ )?"-m \"$trans->{name}\"":"";            # for MARC translations
+            # if not installing MARC po file, ignore all MARC files
+            @nomarc      = ( 'marc21', 'unimarc', 'normarc' ) if ( $trans->{name} !~ /MARC/ ); # hardcoded MARC variants
+
+            system
+                "$self->{process} install " .
+                "-i $trans_dir " .
+                "-o $lang_dir  ".
+                "-s $self->{path_po}/$self->{lang}$trans->{suffix} -r " .
+                "$marc " .
+                ( @files   ? ' -f ' . join ' -f ', @files : '') .
+                ( @nomarc  ? ' -n ' . join ' -n ', @nomarc : '');
+        }
     }
 }
 
 
 sub update_tmpl {
-    my $self = shift;
+    my ($self, $files) = @_;
 
-    print
-        "Update templates\n";
-    while ( my ($interface, $tmpl) = each %{$self->{interface}} ) {
+    say "Update templates" if $self->{verbose};
+    for my $trans ( @{$self->{interface}} ) {
+        my @files   = @$files;
+        my @nomarc = ();
         print
-            "  Update templates '$interface'\n",
-            "    From: $tmpl->{dir}/en/\n",
-            "    To  : $self->{path_po}/$self->{lang}$tmpl->{suffix}\n";
-        my $lang_dir = "$tmpl->{dir}/$self->{lang}";
-        mkdir $lang_dir unless -d $lang_dir;
+            "  Update templates '$trans->{name}'\n",
+            "    From: $trans->{dir}/en/\n",
+            "    To  : $self->{path_po}/$self->{lang}$trans->{suffix}\n"
+                if $self->{verbose};
+
+        my $trans_dir = join("/en/ -i ",split(" ",$trans->{dir}))."/en/"; # multiple source dirs
+        # if processing MARC po file, only use corresponding files
+        my $marc      = ( $trans->{name} =~ /MARC/ )?"-m \"$trans->{name}\"":"";            # for MARC translations
+        # if not processing MARC po file, ignore all MARC files
+        @nomarc       = ( 'marc21', 'unimarc', 'normarc' ) if ( $trans->{name} !~ /MARC/ );      # hardcoded MARC variants
+
         system
-            "$self->{translator_path}/tmpl_process3.pl update " .
-            "-i $tmpl->{dir}/en/ " .
-            "-s $self->{path_po}/$self->{lang}$tmpl->{suffix} -r"
+            "$self->{process} update " .
+            "-i $trans_dir " .
+            "-s $self->{path_po}/$self->{lang}$trans->{suffix} -r " .
+            "$marc "     .
+            ( @files   ? ' -f ' . join ' -f ', @files : '') .
+            ( @nomarc  ? ' -n ' . join ' -n ', @nomarc : '');
     }
 }
 
@@ -333,62 +463,657 @@ sub update_tmpl {
 sub create_prefs {
     my $self = shift;
 
+    if ( -e $self->po_filename("-pref.po") ) {
+        say "Preferences .po file already exists. Delete it if you want to recreate it.";
+        return;
+    }
     $self->get_po_from_prefs();
     $self->save_po();
 }
 
+sub get_po_from_target {
+    my $self   = shift;
+    my $target = shift;
+
+    my $po;
+    my $po_head = new Locale::PO;
+    $po_head->{msgid}  = "\"\"";
+    $po_head->{msgstr} = "".
+        "Project-Id-Version: Koha Project - Installation files\\n" .
+        "PO-Revision-Date: YEAR-MO-DA HO:MI +ZONE\\n" .
+        "Last-Translator: FULL NAME <EMAIL\@ADDRESS>\\n" .
+        "Language-Team: Koha Translation Team\\n" .
+        "Language: ".$self->{lang}."\\n" .
+        "MIME-Version: 1.0\\n" .
+        "Content-Type: text/plain; charset=UTF-8\\n" .
+        "Content-Transfer-Encoding: 8bit\\n";
+
+    my @dirs = @{ $target->{dirs} };
+    my $intradir = $self->{context}->config('intranetdir');
+    for my $dir ( @dirs ) {                                                     # each dir
+        opendir( my $dh, "$intradir/$dir" ) or die ("Can't open $intradir/$dir");
+        my @filelist = grep { $_ =~ m/\.yml/ } readdir($dh);                    # Just yaml files
+        close($dh);
+        for my $file ( @filelist ) {                                            # each file
+            my $yaml   = LoadFile( "$intradir/$dir/$file" );
+            my @tables = @{ $yaml->{'tables'} };
+            my $tablec;
+            for my $table ( @tables ) {                                         # each table
+                $tablec++;
+                my $table_name = ( keys %$table )[0];
+                my @translatable = @{ $table->{$table_name}->{translatable} };
+                my @rows = @{ $table->{$table_name}->{rows} };
+                my @multiline = @{ $table->{$table_name}->{'multiline'} };      # to check multiline values
+                my $rowc;
+                for my $row ( @rows ) {                                         # each row
+                    $rowc++;
+                    for my $field ( @translatable ) {                           # each field
+                        if ( @multiline and grep { $_ eq $field } @multiline ) {    # multiline fields, only notices ATM
+                            my $mulc;
+                            foreach my $line ( @{$row->{$field}} ) {
+                                $mulc++;
+                                next if ( $line =~ /^(\s*<.*?>\s*$|^\s*\[.*?\]\s*|\s*)$/ );                     # discard pure html, TT, empty
+                                $line =~ s/(<<.*?>>|\[\%.*?\%\]|<.*?>)/\%s/g;                                   # put placeholders
+                                next if ( $line =~ /^(\s|%s|-|[[:punct:]]|\(|\))*$/ or length($line) < 2 );     # discard non strings
+                                if ( not $po->{ $line } ) {
+                                    my $msg = new Locale::PO(
+                                                -msgid => $line, -msgstr => '',
+                                                -reference => "$dir/$file:$table_name:$tablec:row:$rowc:mul:$mulc" );
+                                    $po->{ $line } = $msg;
+                                }
+                            }
+                        } else {
+                            if ( defined $row->{$field} and length($row->{$field}) > 1                         # discard null values and small strings
+                                 and not $po->{ $row->{$field} } ) {
+                                my $msg = new Locale::PO(
+                                            -msgid => $row->{$field}, -msgstr => '',
+                                            -reference => "$dir/$file:$table_name:$tablec:row:$rowc" );
+                                $po->{ $row->{$field} } = $msg;
+                            }
+                        }
+                    }
+                }
+            }
+            my $desccount;
+            for my $description ( @{ $yaml->{'description'} } ) {
+                $desccount++;
+                if ( length($description) > 1 and not $po->{ $description } ) {
+                    my $msg = new Locale::PO(
+                                -msgid => $description, -msgstr => '',
+                                -reference => "$dir/$file:description:$desccount" );
+                    $po->{ $description } = $msg;
+                }
+            }
+        }
+    }
+    $po->{''} = $po_head if ( $po );
 
-sub create_tmpl {
+    return $po;
+}
+
+sub create_installer {
     my $self = shift;
+    return unless ( $self->{installer} );
+
+    say "Create installer translation files\n" if $self->{verbose};
 
-    print
-        "Create templates\n";
-    while ( my ($interface, $tmpl) = each %{$self->{interface}} ) {
+    my @targets = @{ $self->{installer} };             # each installer target (common,marc21,unimarc)
+
+    for my $target ( @targets ) {
+        if ( -e $self->po_filename( $target->{suffix} ) ) {
+            say "$self->{lang}$target->{suffix} file already exists. Delete it if you want to recreate it.";
+            return;
+        }
+    }
+
+    for my $target ( @targets ) {
+        my $po = get_po_from_target( $self, $target );
+        # create output file only if there is something to write
+        if ( $po ) {
+            my $po_file = $self->po_filename( $target->{suffix} );
+            Locale::PO->save_file_fromhash( $po_file, $po );
+            say "Saved in file: ", $po_file if $self->{verbose};
+        }
+    }
+}
+
+sub update_installer {
+    my $self = shift;
+    return unless ( $self->{installer} );
+
+    say "Update installer translation files\n" if $self->{verbose};
+
+    my @targets = @{ $self->{installer} };             # each installer target (common,marc21,unimarc)
+
+    for my $target ( @targets ) {
+        return unless ( -e $self->po_filename( $target->{suffix} ) );
+        my $po = get_po_from_target( $self, $target );
+        # update file only if there is something to update
+        if ( $po ) {
+            my ( $fh, $po_temp ) = tempfile();
+            binmode( $fh, ":encoding(UTF-8)" );
+            Locale::PO->save_file_fromhash( $po_temp, $po );
+            my $po_file = $self->po_filename( $target->{suffix} );
+            eval {
+                my $st = system($self->{msgmerge}." ".($self->{verbose}?'':'-q').
+                         " -s $po_file $po_temp -o - | ".$self->{msgattrib}." --no-obsolete -o $po_file");
+            };
+            say "Updated file: ", $po_file if $self->{verbose};
+        }
+    }
+}
+
+sub translate_yaml {
+    my $self   = shift;
+    my $target = shift;
+    my $srcyml = shift;
+
+    my $po_file = $self->po_filename( $target->{suffix} );
+    return $srcyml unless ( -e $po_file );
+
+    my $po_ref  = Locale::PO->load_file_ashash( $po_file );
+
+    my $dstyml   = LoadFile( $srcyml );
+
+    # translate fields in table rows
+    my @tables = @{ $dstyml->{'tables'} };
+    for my $table ( @tables ) {                                                         # each table
+        my $table_name = ( keys %$table )[0];
+        my @translatable = @{ $table->{$table_name}->{translatable} };
+        my @rows = @{ $table->{$table_name}->{rows} };
+        my @multiline = @{ $table->{$table_name}->{'multiline'} };                      # to check multiline values
+        for my $row ( @rows ) {                                                         # each row
+            for my $field ( @translatable ) {                                           # each translatable field
+                if ( @multiline and grep { $_ eq $field } @multiline ) {                # multiline fields, only notices ATM
+                    foreach my $line ( @{$row->{$field}} ) {
+                        next if ( $line =~ /^(\s*<.*?>\s*$|^\s*\[.*?\]\s*|\s*)$/ );     # discard pure html, TT, empty
+                        my @ttvar;
+                        while ( $line =~ s/(<<.*?>>|\[\%.*?\%\]|<.*?>)/\%s/ ) {         # put placeholders, save matches
+                            my $var = $1;
+                            push @ttvar, $var;
+                        }
+
+                        if ( $line =~ /^(\s|%s|-|[[:punct:]]|\(|\))*$/ ) {              # ignore non strings
+                            while ( @ttvar ) {                                          # restore placeholders
+                                my $var = shift @ttvar;
+                                $line =~ s/\%s/$var/;
+                            }
+                            next;
+                        } else {
+                            my $po = $po_ref->{"\"$line\""};                            # quoted key
+                            if ( $po  and not defined( $po->fuzzy() )                   # not fuzzy
+                                      and length( $po->msgid() ) > 2                    # not empty msgid
+                                      and length( $po->msgstr() ) > 2 ) {               # not empty msgstr
+                                $line = $po->dequote( $po->msgstr() );
+                            }
+                            while ( @ttvar ) {                                          # restore placeholders
+                                my $var = shift @ttvar;
+                                $line =~ s/\%s/$var/;
+                            }
+                        }
+                    }
+                } else {
+                    next unless defined $row->{$field};                                 # next if null value
+                    my $po = $po_ref->{"\"$row->{$field}\""};                           # quoted key
+                    if ( $po  and not defined( $po->fuzzy() )                           # not fuzzy
+                              and length( $po->msgid() ) > 2                            # not empty msgid
+                              and length( $po->msgstr() ) > 2 ) {                       # not empty msgstr
+                        $row->{$field} = $po->dequote( $po->msgstr() );
+                    }
+                }
+            }
+        }
+    }
+
+    # translate descriptions
+    for my $description ( @{ $dstyml->{'description'} } ) {
+        my $po = $po_ref->{"\"$description\""};
+        if ( $po  and not defined( $po->fuzzy() )
+                  and length( $po->msgid() ) > 2
+                  and length( $po->msgstr() ) > 2 ) {
+            $description = $po->dequote( $po->msgstr() );
+        }
+    }
+
+    return $dstyml;
+}
+
+sub install_installer {
+    my $self = shift;
+    return unless ( $self->{installer} );
+
+    my $intradir  = $self->{context}->config('intranetdir');
+    my $db_scheme = $self->{context}->config('db_scheme');
+    my $langdir  = "$intradir/installer/data/$db_scheme/$self->{lang}";
+    if ( -d $langdir ) {
+        say "$self->{lang} installer dir $langdir already exists.\nDelete it if you want to recreate it." if $self->{verbose};
+        return;
+    }
+
+    say "Install installer files\n" if $self->{verbose};
+
+    for my $target ( @{ $self->{installer} } ) {
+        return unless ( -e $self->po_filename( $target->{suffix} ) );
+        for my $dir ( @{ $target->{dirs} } ) {
+            ( my $tdir = "$dir" ) =~ s|/en/|/$self->{lang}/|;
+            make_path("$intradir/$tdir");
+
+            opendir( my $dh, "$intradir/$dir" ) or die ("Can't open $intradir/$dir");
+            my @files = grep { ! /^\.+$/ } readdir($dh);
+            close($dh);
+
+            for my $file ( @files ) {
+                if ( $file =~ /yml$/ ) {
+                    my $translated_yaml = translate_yaml( $self, $target, "$intradir/$dir/$file" );
+                    open(my $fh, ">:encoding(UTF-8)", "$intradir/$tdir/$file");
+                    DumpFile( $fh, $translated_yaml );
+                    close($fh);
+                } else {
+                    File::Copy::copy( "$intradir/$dir/$file", "$intradir/$tdir/$file" );
+                }
+            }
+        }
+    }
+}
+
+sub create_tmpl {
+    my ($self, $files) = @_;
+
+    say "Create templates\n" if $self->{verbose};
+    for my $trans ( @{$self->{interface}} ) {
+        my @files   = @$files;
+        my @nomarc = ();
         print
-            "  Create templates .po files for '$interface'\n",
-            "    From: $tmpl->{dir}/en/\n",
-            "    To  : $self->{path_po}/$self->{lang}$tmpl->{suffix}\n";
+            "  Create templates .po files for '$trans->{name}'\n",
+            "    From: $trans->{dir}/en/\n",
+            "    To  : $self->{path_po}/$self->{lang}$trans->{suffix}\n"
+                if $self->{verbose};
+
+        my $trans_dir = join("/en/ -i ",split(" ",$trans->{dir}))."/en/"; # multiple source dirs
+        # if processing MARC po file, only use corresponding files
+        my $marc      = ( $trans->{name} =~ /MARC/ )?"-m \"$trans->{name}\"":"";            # for MARC translations
+        # if not processing MARC po file, ignore all MARC files
+        @nomarc       = ( 'marc21', 'unimarc', 'normarc' ) if ( $trans->{name} !~ /MARC/ ); # hardcoded MARC variants
+
         system
-            "$self->{translator_path}/tmpl_process3.pl create " .
-            "-i $tmpl->{dir}/en/ " .
-            "-s $self->{path_po}/$self->{lang}$tmpl->{suffix} -r"
+            "$self->{process} create " .
+            "-i $trans_dir " .
+            "-s $self->{path_po}/$self->{lang}$trans->{suffix} -r " .
+            "$marc " .
+            ( @files  ? ' -f ' . join ' -f ', @files   : '') .
+            ( @nomarc ? ' -n ' . join ' -n ', @nomarc : '');
     }
 }
 
+sub locale_name {
+    my $self = shift;
 
-sub install {
+    my ($language, $region, $country) = split /-/, $self->{lang};
+    $country //= $region;
+    my $locale = $language;
+    if ($country && length($country) == 2) {
+        $locale .= '_' . $country;
+    }
+
+    return $locale;
+}
+
+sub create_messages {
+    my $self = shift;
+
+    my $pot = "$Bin/$self->{domain}.pot";
+    my $po = "$self->{path_po}/$self->{lang}-messages.po";
+    my $js_pot = "$self->{domain}-js.pot";
+    my $js_po = "$self->{path_po}/$self->{lang}-messages-js.po";
+
+    unless ( -f $pot && -f $js_pot ) {
+        $self->extract_messages();
+    }
+
+    say "Create messages ($self->{lang})" if $self->{verbose};
+    my $locale = $self->locale_name();
+    system "$self->{msginit} -i $pot -o $po -l $locale --no-translator 2> /dev/null";
+    warn "Problems creating $pot ".$? if ( $? == -1 );
+    system "$self->{msginit} -i $js_pot -o $js_po -l $locale --no-translator 2> /dev/null";
+    warn "Problems creating $js_pot ".$? if ( $? == -1 );
+
+    # If msginit failed to correctly set Plural-Forms, set a default one
+    system "$self->{sed} --in-place "
+        . "--expression='s/Plural-Forms: nplurals=INTEGER; plural=EXPRESSION/Plural-Forms: nplurals=2; plural=(n != 1)/' "
+        . "$po $js_po";
+}
+
+sub update_messages {
     my $self = shift;
+
+    my $pot = "$Bin/$self->{domain}.pot";
+    my $po = "$self->{path_po}/$self->{lang}-messages.po";
+    my $js_pot = "$self->{domain}-js.pot";
+    my $js_po = "$self->{path_po}/$self->{lang}-messages-js.po";
+
+    unless ( -f $pot && -f $js_pot ) {
+        $self->extract_messages();
+    }
+
+    if ( -f $po && -f $js_pot ) {
+        say "Update messages ($self->{lang})" if $self->{verbose};
+        system "$self->{msgmerge} --backup=off --quiet -U $po $pot";
+        system "$self->{msgmerge} --backup=off --quiet -U $js_po $js_pot";
+    } else {
+        $self->create_messages();
+    }
+}
+
+sub extract_messages_from_templates {
+    my ($self, $tempdir, $type, @files) = @_;
+
+    my $htdocs = $type eq 'intranet' ? 'intrahtdocs' : 'opachtdocs';
+    my $dir = $self->{context}->config($htdocs);
+    my @keywords = qw(t tx tn txn tnx tp tpx tnp tnpx);
+    my $parser = Template::Parser->new();
+
+    foreach my $file (@files) {
+        say "Extract messages from $file" if $self->{verbose};
+        my $template = read_file(File::Spec->catfile($dir, $file));
+
+        # No need to process a file that doesn't use the i18n.inc file.
+        next unless $template =~ /i18n\.inc/;
+
+        my $data = $parser->parse($template);
+        unless ($data) {
+            warn "Error at $file : " . $parser->error();
+            next;
+        }
+
+        my $destfile = $type eq 'intranet' ?
+            File::Spec->catfile($tempdir, 'koha-tmpl', 'intranet-tmpl', $file) :
+            File::Spec->catfile($tempdir, 'koha-tmpl', 'opac-tmpl', $file);
+
+        make_path(dirname($destfile));
+        open my $fh, '>', $destfile;
+
+        my @blocks = ($data->{BLOCK}, values %{ $data->{DEFBLOCKS} });
+        foreach my $block (@blocks) {
+            my $document = PPI::Document->new(\$block);
+
+            # [% t('foo') %] is compiled to
+            # $output .= $stash->get(['t', ['foo']]);
+            # We try to find all nodes corresponding to keyword (here 't')
+            my $nodes = $document->find(sub {
+                my ($topnode, $element) = @_;
+
+                # Filter out non-valid keywords
+                return 0 unless ($element->isa('PPI::Token::Quote::Single'));
+                return 0 unless (grep {$element->content eq qq{'$_'}} @keywords);
+
+                # keyword (e.g. 't') should be the first element of the arrayref
+                # passed to $stash->get()
+                return 0 if $element->sprevious_sibling;
+
+                return 0 unless $element->snext_sibling
+                    && $element->snext_sibling->snext_sibling
+                    && $element->snext_sibling->snext_sibling->isa('PPI::Structure::Constructor');
+
+                # Check that it's indeed a call to $stash->get()
+                my $statement = $element->statement->parent->statement->parent->statement;
+                return 0 unless grep { $_->isa('PPI::Token::Symbol') && $_->content eq '$stash' } $statement->children;
+                return 0 unless grep { $_->isa('PPI::Token::Operator') && $_->content eq '->' } $statement->children;
+                return 0 unless grep { $_->isa('PPI::Token::Word') && $_->content eq 'get' } $statement->children;
+
+                return 1;
+            });
+
+            next unless $nodes;
+
+            # Write the Perl equivalent of calls to t* functions family, so
+            # xgettext can extract the strings correctly
+            foreach my $node (@$nodes) {
+                my @args = map {
+                    $_->significant && !$_->isa('PPI::Token::Operator') ? $_->content : ()
+                } $node->snext_sibling->snext_sibling->find_first('PPI::Statement')->children;
+
+                my $keyword = $node->content;
+                $keyword =~ s/^'t(.*)'$/__$1/;
+
+                # Only keep required args to have a clean output
+                my @required_args = shift @args;
+                push @required_args, shift @args if $keyword =~ /n/;
+                push @required_args, shift @args if $keyword =~ /p/;
+
+                say $fh "$keyword(" . join(', ', @required_args) . ");";
+            }
+
+        }
+
+        close $fh;
+    }
+
+    return $tempdir;
+}
+
+sub extract_messages {
+    my $self = shift;
+
+    say "Extract messages into POT file" if $self->{verbose};
+
+    my $intranetdir = $self->{context}->config('intranetdir');
+    my $opacdir = $self->{context}->config('opacdir');
+
+    # Find common ancestor directory
+    my @intranetdirs = File::Spec->splitdir($intranetdir);
+    my @opacdirs = File::Spec->splitdir($opacdir);
+    my @basedirs;
+    while (@intranetdirs and @opacdirs) {
+        my ($dir1, $dir2) = (shift @intranetdirs, shift @opacdirs);
+        last if $dir1 ne $dir2;
+        push @basedirs, $dir1;
+    }
+    my $basedir = File::Spec->catdir(@basedirs);
+
+    my @files_to_scan;
+    my @directories_to_scan = ('.');
+    my @blacklist = map { File::Spec->catdir(@intranetdirs, $_) } qw(blib koha-tmpl skel tmp t);
+    while (@directories_to_scan) {
+        my $dir = shift @directories_to_scan;
+        opendir DIR, File::Spec->catdir($basedir, $dir) or die "Unable to open $dir: $!";
+        foreach my $entry (readdir DIR) {
+            next if $entry =~ /^\./;
+            my $relentry = File::Spec->catfile($dir, $entry);
+            my $abspath = File::Spec->catfile($basedir, $relentry);
+            if (-d $abspath and not grep { $_ eq $relentry } @blacklist) {
+                push @directories_to_scan, $relentry;
+            } elsif (-f $abspath and $relentry =~ /\.(pl|pm)$/) {
+                push @files_to_scan, $relentry;
+            }
+        }
+    }
+
+    my $intrahtdocs = $self->{context}->config('intrahtdocs');
+    my $opachtdocs = $self->{context}->config('opachtdocs');
+
+    my @intranet_tt_files;
+    find(sub {
+        if ($File::Find::dir =~ m|/en/| && $_ =~ m/\.(tt|inc)$/) {
+            my $filename = $File::Find::name;
+            $filename =~ s|^$intrahtdocs/||;
+            push @intranet_tt_files, $filename;
+        }
+    }, $intrahtdocs);
+
+    my @opac_tt_files;
+    find(sub {
+        if ($File::Find::dir =~ m|/en/| && $_ =~ m/\.(tt|inc)$/) {
+            my $filename = $File::Find::name;
+            $filename =~ s|^$opachtdocs/||;
+            push @opac_tt_files, $filename;
+        }
+    }, $opachtdocs);
+
+    my $tempdir = tempdir('Koha-translate-XXXX', TMPDIR => 1, CLEANUP => 1);
+    $self->extract_messages_from_templates($tempdir, 'intranet', @intranet_tt_files);
+    $self->extract_messages_from_templates($tempdir, 'opac', @opac_tt_files);
+
+    @intranet_tt_files = map { File::Spec->catfile('koha-tmpl', 'intranet-tmpl', $_) } @intranet_tt_files;
+    @opac_tt_files = map { File::Spec->catfile('koha-tmpl', 'opac-tmpl', $_) } @opac_tt_files;
+    my @tt_files = grep { -e File::Spec->catfile($tempdir, $_) } @intranet_tt_files, @opac_tt_files;
+
+    push @files_to_scan, @tt_files;
+
+    my $xgettext_common_args = "--force-po --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 ";
+    my $xgettext_cmd = "$self->{xgettext} -L Perl $xgettext_common_args "
+        . "-o $Bin/$self->{domain}.pot -D $tempdir -D $basedir";
+    $xgettext_cmd .= " $_" foreach (@files_to_scan);
+
+    if (system($xgettext_cmd) != 0) {
+        die "system call failed: $xgettext_cmd";
+    }
+
+    my @js_dirs = (
+        "$intrahtdocs/prog/js",
+        "$opachtdocs/bootstrap/js",
+    );
+
+    my @js_files;
+    find(sub {
+        if ($_ =~ m/\.js$/) {
+            my $filename = $File::Find::name;
+            $filename =~ s|^$intranetdir/||;
+            push @js_files, $filename;
+        }
+    }, @js_dirs);
+
+    $xgettext_cmd = "$self->{xgettext} -L JavaScript $xgettext_common_args "
+        . "-o $Bin/$self->{domain}-js.pot -D $intranetdir";
+    $xgettext_cmd .= " $_" foreach (@js_files);
+
+    if (system($xgettext_cmd) != 0) {
+        die "system call failed: $xgettext_cmd";
+    }
+
+    my $replace_charset_cmd = "$self->{sed} --in-place " .
+        "--expression='s/charset=CHARSET/charset=UTF-8/' " .
+        "$Bin/$self->{domain}.pot $Bin/$self->{domain}-js.pot";
+    if (system($replace_charset_cmd) != 0) {
+        die "system call failed: $replace_charset_cmd";
+    }
+}
+
+sub install_messages {
+    my ($self) = @_;
+
+    my $locale = $self->locale_name();
+    my $modir = "$self->{path_po}/$locale/LC_MESSAGES";
+    my $pofile = "$self->{path_po}/$self->{lang}-messages.po";
+    my $mofile = "$modir/$self->{domain}.mo";
+    my $js_pofile = "$self->{path_po}/$self->{lang}-messages-js.po";
+
+    unless ( -f $pofile && -f $js_pofile ) {
+        $self->create_messages();
+    }
+    say "Install messages ($locale)" if $self->{verbose};
+    make_path($modir);
+    system "$self->{msgfmt} -o $mofile $pofile";
+
+    my $js_locale_data = 'var json_locale_data = {"Koha":' . `$self->{po2json} $js_pofile` . '};';
+    my $progdir = $self->{context}->config('intrahtdocs') . '/prog';
+    mkdir "$progdir/$self->{lang}/js";
+    open my $fh, '>', "$progdir/$self->{lang}/js/locale_data.js";
+    print $fh $js_locale_data;
+    close $fh;
+
+    my $opachtdocs = $self->{context}->config('opachtdocs');
+    opendir(my $dh, $opachtdocs);
+    for my $theme ( grep { not /^\.|lib|xslt/ } readdir($dh) ) {
+        mkdir "$opachtdocs/$theme/$self->{lang}/js";
+        open my $fh, '>', "$opachtdocs/$theme/$self->{lang}/js/locale_data.js";
+        print $fh $js_locale_data;
+        close $fh;
+    }
+}
+
+sub remove_pot {
+    my $self = shift;
+
+    unlink "$Bin/$self->{domain}.pot";
+    unlink "$Bin/$self->{domain}-js.pot";
+}
+
+sub compress {
+    my ($self, $files) = @_;
+    my @langs = $self->{lang} ? ($self->{lang}) : $self->get_all_langs();
+    for my $lang ( @langs ) {
+        $self->set_lang( $lang );
+        opendir( my $dh, $self->{path_po} );
+        my @files = grep { $_ =~ /^$self->{lang}.*po$/ } readdir $dh;
+        foreach my $file ( @files ) {
+            say "Compress file $file" if $self->{verbose};
+            system "$self->{gzip} -9 $self->{path_po}/$file";
+        }
+    }
+}
+
+sub uncompress {
+    my ($self, $files) = @_;
+    my @langs = $self->{lang} ? ($self->{lang}) : $self->get_all_langs();
+    for my $lang ( @langs ) {
+        opendir( my $dh, $self->{path_po} );
+        $self->set_lang( $lang );
+        my @files = grep { $_ =~ /^$self->{lang}.*po.gz$/ } readdir $dh;
+        foreach my $file ( @files ) {
+            say "Uncompress file $file" if $self->{verbose};
+            system "$self->{gunzip} $self->{path_po}/$file";
+        }
+    }
+}
+
+sub install {
+    my ($self, $files) = @_;
     return unless $self->{lang};
-    $self->install_tmpl() unless $self->{pref_only};
+    $self->uncompress();
+    $self->install_tmpl($files) unless $self->{pref_only};
     $self->install_prefs();
+    $self->install_messages();
+    $self->remove_pot();
+    $self->install_installer();
 }
 
 
 sub get_all_langs {
     my $self = shift;
     opendir( my $dh, $self->{path_po} );
-    my @files = grep { $_ =~ /-i-opac-t-prog-v-3006000.po$/ }
+    my @files = grep { $_ =~ /-pref.(po|po.gz)$/ }
         readdir $dh;
-    @files = map { $_ =~ s/-i-opac-t-prog-v-3006000.po$//; $_ } @files;
+    @files = map { $_ =~ s/-pref.(po|po.gz)$//r } @files;
 }
 
 
 sub update {
-    my $self = shift;
+    my ($self, $files) = @_;
     my @langs = $self->{lang} ? ($self->{lang}) : $self->get_all_langs();
     for my $lang ( @langs ) {
         $self->set_lang( $lang );
-        $self->update_tmpl() unless $self->{pref_only};
+        $self->uncompress();
+        $self->update_tmpl($files) unless $self->{pref_only};
         $self->update_prefs();
+        $self->update_messages();
+        $self->update_installer();
     }
+    $self->remove_pot();
 }
 
 
 sub create {
-    my $self = shift;
+    my ($self, $files) = @_;
     return unless $self->{lang};
-    $self->create_tmpl() unless $self->{pref_only};
+    $self->create_tmpl($files) unless $self->{pref_only};
     $self->create_prefs();
+    $self->create_messages();
+    $self->remove_pot();
+    $self->create_installer();
 }
 
 
@@ -436,17 +1161,21 @@ appropriate directory.
 
 =item translate create F<lang>
 
-Create 3 .po files in F<po> subdirectory: (1) from opac pages templates, (2)
-intranet templates, and (3) from preferences.
+Create 4 kinds of .po files in F<po> subdirectory:
+(1) one from each theme on opac pages templates,
+(2) intranet templates,
+(3) preferences, and
+(4) one for each MARC dialect.
+
 
 =over
 
-=item F<lang>-opac.po
+=item F<lang>-opac-{theme}.po
 
 Contains extracted text from english (en) OPAC templates found in
-<KOHA_ROOT>/koha-tmpl/opac-tmpl/prog/en/ directory.
+<KOHA_ROOT>/koha-tmpl/opac-tmpl/{theme}/en/ directory.
 
-=item F<lang>-intranet.po
+=item F<lang>-staff-prog.po
 
 Contains extracted text from english (en) intranet templates found in
 <KOHA_ROOT>/koha-tmpl/intranet-tmpl/prog/en/ directory.
@@ -457,6 +1186,11 @@ Contains extracted text from english (en) preferences. They are found in files
 located in <KOHA_ROOT>/koha-tmpl/intranet-tmpl/prog/en/admin/preferences
 directory.
 
+=item F<lang>-marc-{MARC}.po
+
+Contains extracted text from english (en) files from opac and intranet,
+related with MARC dialects.
+
 =back
 
 =item pref-trans update F<lang>