5e6a8a5327f4f5eb39f5cf3e62952beeddf9e374
[kivitendo-erp.git] / scripts / locales.pl
1 #!/usr/bin/perl
2
3 # -n do not include custom_ scripts
4 # -v verbose mode, shows progress stuff
5
6 # this version of locles processes not only all required .pl files
7 # but also all parse_html_templated files.
8
9 use utf8;
10 use strict;
11
12 use Carp;
13 use Data::Dumper;
14 use English;
15 use File::Slurp qw(slurp);
16 use FileHandle;
17 use Getopt::Long;
18 use IO::Dir;
19 use List::Util qw(first);
20 use POSIX;
21 use Pod::Usage;
22
23 $OUTPUT_AUTOFLUSH = 1;
24
25 my $opt_v  = 0;
26 my $opt_n  = 0;
27 my $opt_c  = 0;
28 my $debug  = 0;
29
30 parse_args();
31
32 my $basedir      = "../..";
33 my $locales_dir  = ".";
34 my $bindir       = "$basedir/bin/mozilla";
35 my @progdirs     = ( "$basedir/SL" );
36 my $dbupdir      = "$basedir/sql/Pg-upgrade";
37 my $dbupdir2     = "$basedir/sql/Pg-upgrade2";
38 my $menufile     = "menu.ini";
39 my $submitsearch = qr/type\s*=\s*[\"\']?submit/i;
40 our $self        = {};
41 our $missing     = {};
42 our @lost        = ();
43
44 my (%referenced_html_files, %locale, %htmllocale, %alllocales, %cached, %submit);
45 my ($ALL_HEADER, $MISSING_HEADER, $LOST_HEADER);
46
47 init();
48
49 sub find_files {
50   my ($top_dir_name) = @_;
51
52   my (@files, $finder);
53
54   $finder = sub {
55     my ($dir_name) = @_;
56
57     tie my %dir_h, 'IO::Dir', $dir_name;
58
59     push @files,   grep { -f } map { "${dir_name}/${_}" }                       keys %dir_h;
60     my @sub_dirs = grep { -d } map { "${dir_name}/${_}" } grep { ! m/^\.\.?$/ } keys %dir_h;
61
62     $finder->($_) for @sub_dirs;
63   };
64
65   $finder->($top_dir_name);
66
67   return @files;
68 }
69
70 sub merge_texts {
71 # overwrite existing entries with the ones from 'missing'
72   $self->{texts}->{$_} = $missing->{$_} for grep { $missing->{$_} } keys %alllocales;
73
74   # try to set missing entries from lost ones
75   my %lost_by_text = map { ($_->{text} => $_->{translation}) } @lost;
76   $self->{texts}->{$_} = $lost_by_text{$_} for grep { !$self->{texts}{$_} } keys %alllocales;
77 }
78
79 my @bindir_files = find_files($bindir);
80 my @progfiles    = map { m:^(.+)/([^/]+)$:; [ $2, $1 ]  } grep { /\.pl$/ && !/_custom/ } @bindir_files;
81 my @customfiles  = grep /_custom/, @bindir_files;
82
83 push @progfiles, map { m:^(.+)/([^/]+)$:; [ $2, $1 ] } grep { /\.pm$/ } map { find_files($_) } @progdirs;
84
85 # put customized files into @customfiles
86 my (@menufiles, %dir_h);
87
88 if ($opt_n) {
89   @customfiles = ();
90   @menufiles   = ($menufile);
91 } else {
92   tie %dir_h, 'IO::Dir', $basedir;
93   @menufiles = map { "$basedir/$_" } grep { /.*?_$menufile$/ } keys %dir_h;
94   unshift @menufiles, "$basedir/$menufile";
95 }
96
97 tie %dir_h, 'IO::Dir', $dbupdir;
98 my @dbplfiles = grep { /\.pl$/ } keys %dir_h;
99
100 tie %dir_h, 'IO::Dir', $dbupdir2;
101 my @dbplfiles2 = grep { /\.pl$/ } keys %dir_h;
102
103 # slurp the translations in
104 if (-f "$locales_dir/all") {
105   require "$locales_dir/all";
106 }
107 if (-f "$locales_dir/missing") {
108   require "$locales_dir/missing" ;
109   unlink "$locales_dir/missing";
110 }
111 if (-f "$locales_dir/lost") {
112   require "$locales_dir/lost";
113   unlink "$locales_dir/lost";
114 }
115
116 my $charset = slurp("$locales_dir/charset") || 'utf-8';
117 chomp $charset;
118
119 my %old_texts = %{ $self->{texts} || {} };
120
121 handle_file(@{ $_ })       for @progfiles;
122 handle_file($_, $dbupdir)  for @dbplfiles;
123 handle_file($_, $dbupdir2) for @dbplfiles2;
124 scanmenu($_)               for @menufiles;
125
126 # merge entries to translate with entries from files 'missing' and 'lost'
127 merge_texts();
128
129 # generate all
130 generate_file(
131   file      => "$locales_dir/all",
132   header    => $ALL_HEADER,
133   data_name => '$self->{texts}',
134   data_sub  => sub { _print_line($_, $self->{texts}{$_}, @_) for sort keys %alllocales },
135 );
136
137   foreach my $text (keys %$missing) {
138     if ($locale{$text} || $htmllocale{$text}) {
139       unless ($self->{texts}{$text}) {
140         $self->{texts}{$text} = $missing->{$text};
141       }
142     }
143   }
144
145
146 # calc and generate missing
147 my @new_missing = grep { !$self->{texts}{$_} } sort keys %alllocales;
148
149 if (@new_missing) {
150   generate_file(
151     file      => "$locales_dir/missing",
152     header    => $MISSING_HEADER,
153     data_name => '$missing',
154     data_sub  => sub { _print_line($_, '', @_) for @new_missing },
155   );
156 }
157
158 # calc and generate lost
159 while (my ($text, $translation) = each %old_texts) {
160   next if ($alllocales{$text});
161   push @lost, { 'text' => $text, 'translation' => $translation };
162 }
163
164 if (scalar @lost) {
165   splice @lost, 0, (scalar @lost - 50) if (scalar @lost > 50);
166   generate_file(
167     file      => "$locales_dir/lost",
168     header    => $LOST_HEADER,
169     delim     => '()',
170     data_name => '@lost',
171     data_sub  => sub {
172       _print_line($_->{text}, $_->{translation}, @_, template => "  { 'text' => %s, 'translation' => %s },\n") for @lost;
173     },
174   );
175 }
176
177 my $trlanguage = slurp("$locales_dir/LANGUAGE");
178 chomp $trlanguage;
179
180 search_unused_htmlfiles() if $opt_c;
181
182 my $count  = scalar keys %alllocales;
183 my $notext = scalar @new_missing;
184 my $per    = sprintf("%.1f", ($count - $notext) / $count * 100);
185 print "\n$trlanguage - ${per}%";
186 print " - $notext/$count missing" if $notext;
187 print "\n";
188
189 exit;
190
191 # eom
192
193 sub init {
194   $ALL_HEADER = <<EOL;
195 # These are all the texts to build the translations files.
196 # The file has the form of 'english text'  => 'foreign text',
197 # you can add the translation in this file or in the 'missing' file
198 # run locales.pl from this directory to rebuild the translation files
199 EOL
200   $MISSING_HEADER = <<EOL;
201 # add the missing texts and run locales.pl to rebuild
202 EOL
203   $LOST_HEADER  = <<EOL;
204 # The last 50 text strings, that have been removed.
205 # This file has been auto-generated by locales.pl. Please don't edit!
206 EOL
207 }
208
209 sub parse_args {
210   my ($help, $man);
211
212   GetOptions(
213     'no-custom-files' => \$opt_n,
214     'check-files'     => \$opt_c,
215     'verbose'         => \$opt_v,
216     'help'            => \$help,
217     'man'             => \$man,
218     'debug'           => \$debug,
219   );
220
221   if ($help) {
222     pod2usage(1);
223     exit 0;
224   }
225
226   if ($man) {
227     pod2usage(-exitstatus => 0, -verbose => 2);
228     exit 0;
229   }
230
231   if (@ARGV) {
232     my $arg = shift @ARGV;
233     my $ok  = 0;
234     foreach my $dir ("../locale/$arg", "locale/$arg", "../$arg", $arg) {
235       next unless -d $dir && -f "$dir/all" && -f "$dir/LANGUAGE";
236       $ok = chdir $dir;
237       last;
238     }
239
240     if (!$ok) {
241       print "The locale directory '$arg' could not be found.\n";
242       exit 1;
243     }
244
245   } elsif (!-f 'all' || !-f 'LANGUAGE') {
246     print "locales.pl was not called from a locale/* subdirectory,\n"
247       .   "and no locale directory name was given.\n";
248     exit 1;
249   }
250 }
251
252 sub handle_file {
253   my ($file, $dir) = @_;
254   print "\n$file" if $opt_v;
255   %locale = ();
256   %submit = ();
257
258   &scanfile("$dir/$file");
259
260   # scan custom_{module}.pl or {login}_{module}.pl files
261   foreach my $customfile (@customfiles) {
262     if ($customfile =~ /_$file/) {
263       if (-f "$dir/$customfile") {
264         &scanfile("$dir/$customfile");
265       }
266     }
267   }
268
269   $file =~ s/\.pl//;
270 }
271
272 sub extract_text_between_parenthesis {
273   my ($fh, $line) = @_;
274   my ($inside_string, $pos, $text, $quote_next) = (undef, 0, "", 0);
275
276   while (1) {
277     if (length($line) <= $pos) {
278       $line = <$fh>;
279       return ($text, "") unless ($line);
280       $pos = 0;
281     }
282
283     my $cur_char = substr($line, $pos, 1);
284
285     if (!$inside_string) {
286       if ((length($line) >= ($pos + 3)) && (substr($line, $pos, 2)) eq "qq") {
287         $inside_string = substr($line, $pos + 2, 1);
288         $pos += 2;
289
290       } elsif ((length($line) >= ($pos + 2)) &&
291                (substr($line, $pos, 1) eq "q")) {
292         $inside_string = substr($line, $pos + 1, 1);
293         $pos++;
294
295       } elsif (($cur_char eq '"') || ($cur_char eq '\'')) {
296         $inside_string = $cur_char;
297
298       } elsif (($cur_char eq ")") || ($cur_char eq ',')) {
299         return ($text, substr($line, $pos + 1));
300       }
301
302     } else {
303       if ($quote_next) {
304         $text .= '\\' unless $cur_char eq "'";
305         $text .= $cur_char;
306         $quote_next = 0;
307
308       } elsif ($cur_char eq '\\') {
309         $quote_next = 1;
310
311       } elsif ($cur_char eq $inside_string) {
312         undef($inside_string);
313
314       } else {
315         $text .= $cur_char;
316
317       }
318     }
319     $pos++;
320   }
321 }
322
323 sub scanfile {
324   my $file = shift;
325   my $dont_include_subs = shift;
326   my $scanned_files = shift;
327
328   # sanitize file
329   $file =~ s=/+=/=g;
330
331   $scanned_files = {} unless ($scanned_files);
332   return if ($scanned_files->{$file});
333   $scanned_files->{$file} = 1;
334
335   if (!defined $cached{$file}) {
336
337     return unless (-f "$file");
338
339     my $fh = new FileHandle;
340     open $fh, "$file" or die "$! : $file";
341
342     my ($is_submit, $line_no, $sub_line_no) = (0, 0, 0);
343
344     while (<$fh>) {
345       last if /^\s*__END__/;
346
347       $line_no++;
348
349       # is this another file
350       if (/require\s+\W.*\.pl/) {
351         my $newfile = $&;
352         $newfile =~ s/require\s+\W//;
353         $newfile =~ s|bin/mozilla||;
354          $cached{$file}{scan}{"$bindir/$newfile"} = 1;
355       } elsif (/use\s+SL::([\w:]*)/) {
356         my $module =  $1;
357         $module    =~ s|::|/|g;
358         $cached{$file}{scannosubs}{"../../SL/${module}.pm"} = 1;
359       }
360
361       # is this a template call?
362       if (/(?:parse_html_template2?|render)\s*\(\s*[\"\']([\w\/]+)\s*[\"\']/) {
363         my $new_file_base = "$basedir/templates/webpages/$1.";
364         if (/parse_html_template2/) {
365           print "E: " . strip_base($file) . " is still using 'parse_html_template2' for " . strip_base("${new_file_base}html") . ".\n";
366         }
367
368         my $found_one = 0;
369         foreach my $ext (qw(html js json)) {
370           my $new_file = "${new_file_base}${ext}";
371           if (-f $new_file) {
372             $cached{$file}{scanh}{$new_file} = 1;
373             print "." if $opt_v;
374             $found_one = 1;
375           }
376         }
377
378         if ($opt_c && !$found_one) {
379           print "W: missing HTML template: " . strip_base($new_file_base) . "{html,json,js} (referenced from " . strip_base($file) . ")\n";
380         }
381       }
382
383       my $rc = 1;
384
385       while ($rc) {
386         if (/Locale/) {
387           unless (/^use /) {
388             my ($null, $country) = split /,/;
389             $country =~ s/^ +[\"\']//;
390             $country =~ s/[\"\'].*//;
391           }
392         }
393
394         my $postmatch = "";
395
396         # is it a submit button before $locale->
397         if (/$submitsearch/) {
398           $postmatch = "$'";
399           if ($` !~ /locale->text/) {
400             $is_submit   = 1;
401             $sub_line_no = $line_no;
402           }
403         }
404
405         my ($found) = / (?: locale->text | \b t8 ) \b .*? \(/x;
406         $postmatch = "$'";
407
408         if ($found) {
409           my $string;
410           ($string, $_) = extract_text_between_parenthesis($fh, $postmatch);
411           $postmatch = $_;
412
413           # if there is no $ in the string record it
414           unless (($string =~ /\$\D.*/) || ("" eq $string)) {
415
416             # this guarantees one instance of string
417             $cached{$file}{locale}{$string} = 1;
418
419             # this one is for all the locales
420             $cached{$file}{all}{$string} = 1;
421
422             # is it a submit button before $locale->
423             if ($is_submit) {
424               $cached{$file}{submit}{$string} = 1;
425             }
426           }
427         } elsif ($postmatch =~ />/) {
428           $is_submit = 0;
429         }
430
431         # exit loop if there are no more locales on this line
432         ($rc) = ($postmatch =~ /locale->text | \b t8/x);
433
434         if (   ($postmatch =~ />/)
435             || (!$found && ($sub_line_no != $line_no) && />/)) {
436           $is_submit = 0;
437         }
438       }
439     }
440
441     close($fh);
442
443   }
444
445   $alllocales{$_} = 1             for keys %{$cached{$file}{all}};
446   $locale{$_}     = 1             for keys %{$cached{$file}{locale}};
447   $submit{$_}     = 1             for keys %{$cached{$file}{submit}};
448
449   scanfile($_, 0, $scanned_files) for keys %{$cached{$file}{scan}};
450   scanfile($_, 1, $scanned_files) for keys %{$cached{$file}{scannosubs}};
451   scanhtmlfile($_)                for keys %{$cached{$file}{scanh}};
452
453   $referenced_html_files{$_} = 1  for keys %{$cached{$file}{scanh}};
454 }
455
456 sub scanmenu {
457   my $file = shift;
458
459   my $fh = new FileHandle;
460   open $fh, "$file" or die "$! : $file";
461
462   my @a = grep m/^\[/, <$fh>;
463   close($fh);
464
465   # strip []
466   grep { s/(\[|\])//g } @a;
467
468   foreach my $item (@a) {
469     my @b = split /--/, $item;
470     foreach my $string (@b) {
471       chomp $string;
472       $locale{$string}     = 1;
473       $alllocales{$string} = 1;
474     }
475   }
476
477 }
478
479 sub unescape_template_string {
480   my $in =  "$_[0]";
481   $in    =~ s/\\(.)/$1/g;
482   return $in;
483 }
484
485 sub scanhtmlfile {
486   local *IN;
487
488   my $file = shift;
489
490   if (!defined $cached{$file}) {
491     my %plugins = ( 'loaded' => { }, 'needed' => { } );
492
493     open(IN, $file) || die $file;
494
495     my $copying  = 0;
496     my $issubmit = 0;
497     my $text     = "";
498     while (my $line = <IN>) {
499       chomp($line);
500
501       while ($line =~ m/\[\%[^\w]*use[^\w]+(\w+)[^\w]*?\%\]/gi) {
502         $plugins{loaded}->{$1} = 1;
503       }
504
505       while ($line =~ m/\[\%[^\w]*(\w+)\.\w+\(/g) {
506         my $plugin = $1;
507         $plugins{needed}->{$plugin} = 1 if (first { $_ eq $plugin } qw(HTML LxERP JavaScript MultiColumnIterator L));
508       }
509
510       while ($line =~ m/(?:             # Start von Variante 1: LxERP.t8('...'); ohne darumliegende [% ... %]-Tags
511                           (LxERP\.t8)\( #   LxERP.t8(                             ::Parameter $1::
512                           ([\'\"])      #   Anfang des zu übersetzenden Strings   ::Parameter $2::
513                           (.*?)         #   Der zu übersetzende String            ::Parameter $3::
514                           (?<!\\)\2     #   Ende des zu übersetzenden Strings
515                         |               # Start von Variante 2: [% '...' | $T8 %]
516                           \[\%          #   Template-Start-Tag
517                           [\-~#]?       #   Whitespace-Unterdrückung
518                           \s*           #   Optional beliebig viele Whitespace
519                           ([\'\"])      #   Anfang des zu übersetzenden Strings   ::Parameter $4::
520                           (.*?)         #   Der zu übersetzende String            ::Parameter $5::
521                           (?<!\\)\4     #   Ende des zu übersetzenden Strings
522                           \s*\|\s*      #   Pipe-Zeichen mit optionalen Whitespace davor und danach
523                           (\$T8)        #   Filteraufruf                          ::Parameter $6::
524                           .*?           #   Optionale Argumente für den Filter
525                           \s*           #   Whitespaces
526                           [\-~#]?       #   Whitespace-Unterdrückung
527                           \%\]          #   Template-Ende-Tag
528                         )
529                        /ix) {
530         my $module = $1 || $6;
531         my $string = $3 || $5;
532         print "Found filter >>>$string<<<\n" if $debug;
533         substr $line, $LAST_MATCH_START[1], $LAST_MATCH_END[0] - $LAST_MATCH_START[0], '';
534
535         $string                         = unescape_template_string($string);
536         $cached{$file}{all}{$string}    = 1;
537         $cached{$file}{html}{$string}   = 1;
538         $cached{$file}{submit}{$string} = 1 if $PREMATCH =~ /$submitsearch/;
539         $plugins{needed}->{T8}          = 1 if $module eq '$T8';
540         $plugins{needed}->{LxERP}       = 1 if $module eq 'LxERP.t8';
541       }
542
543       while ($line =~ m/\[\%          # Template-Start-Tag
544                         [\-~#]?       # Whitespace-Unterdrückung
545                         \s*           # Optional beliebig viele Whitespace
546                         (?:           # Die erkannten Template-Direktiven
547                           PROCESS
548                         |
549                           INCLUDE
550                         )
551                         \s+           # Mindestens ein Whitespace
552                         [\'\"]?       # Anfang des Dateinamens
553                         ([^\s]+)      # Beliebig viele Nicht-Whitespaces -- Dateiname
554                         \.html        # Endung ".html", ansonsten kann es der Name eines Blocks sein
555                        /ix) {
556         my $new_file_name = "$basedir/templates/webpages/$1.html";
557         $cached{$file}{scanh}{$new_file_name} = 1;
558         substr $line, $LAST_MATCH_START[1], $LAST_MATCH_END[0] - $LAST_MATCH_START[0], '';
559       }
560     }
561
562     close(IN);
563
564     foreach my $plugin (keys %{ $plugins{needed} }) {
565       next if ($plugins{loaded}->{$plugin});
566       print "E: " . strip_base($file) . " requires the Template plugin '$plugin', but is not loaded with '[\% USE $plugin \%]'.\n";
567     }
568   }
569
570   # copy back into global arrays
571   $alllocales{$_} = 1            for keys %{$cached{$file}{all}};
572   $locale{$_}     = 1            for keys %{$cached{$file}{html}};
573   $submit{$_}     = 1            for keys %{$cached{$file}{submit}};
574
575   scanhtmlfile($_)               for keys %{$cached{$file}{scanh}};
576
577   $referenced_html_files{$_} = 1 for keys %{$cached{$file}{scanh}};
578 }
579
580 sub search_unused_htmlfiles {
581   my @unscanned_dirs = ('../../templates/webpages');
582
583   while (scalar @unscanned_dirs) {
584     my $dir = shift @unscanned_dirs;
585
586     foreach my $entry (<$dir/*>) {
587       if (-d $entry) {
588         push @unscanned_dirs, $entry;
589
590       } elsif (($entry =~ /_master.html$/) && -f $entry && !$referenced_html_files{$entry}) {
591         print "W: unused HTML template: " . strip_base($entry) . "\n";
592
593       }
594     }
595   }
596 }
597
598 sub strip_base {
599   my $s =  "$_[0]";             # Create a copy of the string.
600
601   $s    =~ s|^../../||;
602   $s    =~ s|templates/webpages/||;
603
604   return $s;
605 }
606
607 sub _single_quote {
608   my $val = shift;
609   $val =~ s/(\'|\\$)/\\$1/g;
610   return  "'" . $val .  "'";
611 }
612
613 sub _print_line {
614   my $key      = _single_quote(shift);
615   my $text     = _single_quote(shift);
616   my %params   = @_;
617   my $template = $params{template} || qq|  %-29s => %s,\n|;
618   my $fh       = $params{fh}       || croak 'need filehandle in _print_line';
619
620   print $fh sprintf $template, $key, $text;
621 }
622
623 sub generate_file {
624   my %params = @_;
625
626   my $file      = $params{file}   || croak 'need filename in generate_file';
627   my $header    = $params{header};
628   my $lines     = $params{data_sub};
629   my $data_name = $params{data_name};
630   my @delim     = split //, ($params{delim} || '{}');
631
632   open my $fh, '>:encoding(utf8)', $file or die "$! : $file";
633
634   $charset =~ s/\r?\n//g;
635   my $emacs_charset = lc $charset;
636
637   print $fh "#!/usr/bin/perl\n# -*- coding: $emacs_charset; -*-\n# vim: fenc=$charset\n\nuse utf8;\n\n";
638   print $fh $header, "\n" if $header;
639   print $fh "$data_name = $delim[0]\n" if $data_name;
640
641   $lines->(fh => $fh);
642
643   print $fh qq|$delim[1];\n\n1;\n|;
644   close $fh;
645 }
646
647 sub slurp {
648   my $file = shift;
649   do { local ( @ARGV, $/ ) = $file; <> }
650 }
651
652 __END__
653
654 =head1 NAME
655
656 locales.pl - Collect strings for translation in kivitendo
657
658 =head1 SYNOPSIS
659
660 locales.pl [options] lang_code
661
662  Options:
663   -n, --no-custom-files  Do not process files whose name contains "_"
664   -c, --check-files      Run extended checks on HTML files
665   -v, --verbose          Be more verbose
666   -h, --help             Show this help
667
668 =head1 OPTIONS
669
670 =over 8
671
672 =item B<-n>, B<--no-custom-files>
673
674 Do not process files whose name contains "_", e.g. "custom_io.pl".
675
676 =item B<-c>, B<--check-files>
677
678 Run extended checks on the usage of templates. This can be used to
679 discover HTML templates that are never used as well as the usage of
680 non-existing HTML templates.
681
682 =item B<-v>, B<--verbose>
683
684 Be more verbose.
685
686 =back
687
688 =head1 DESCRIPTION
689
690 This script collects strings from Perl files, the menu.ini file and
691 HTML templates and puts them into the file "all" for translation.
692
693 =cut