package Zonemaster::Engine::Translator; use v5.16.0; use warnings; use version; our $VERSION = version->declare("v1.0.8"); use Carp qw[confess croak]; use Locale::Messages qw[textdomain]; use Locale::TextDomain qw[Zonemaster-Engine]; use POSIX qw[setlocale LC_MESSAGES]; use Readonly; use Zonemaster::Engine::Test; ### ### Tag descriptions ### Readonly my %TAG_DESCRIPTIONS => ( CANNOT_CONTINUE => sub { __x # SYSTEM:CANNOT_CONTINUE "Not enough data about {domain} was found to be able to run tests.", @_; }, DEPENDENCY_VERSION => sub { __x # SYSTEM:DEPENDENCY_VERSION "Using prerequisite module {name} version {version}.", @_; }, GLOBAL_VERSION => sub { __x # SYSTEM:GLOBAL_VERSION "Using version {version} of the Zonemaster engine.", @_; }, LOGGER_CALLBACK_ERROR => sub { __x # SYSTEM:LOGGER_CALLBACK_ERROR "Logger callback died with error: {exception}", @_; }, LOOKUP_ERROR => sub { __x # SYSTEM:LOOKUP_ERROR "DNS query to {ns} for {domain}/{type}/{class} failed with error: {message}", @_; }, MODULE_ERROR => sub { __x # SYSTEM:MODULE_ERROR "Fatal error in {module}: {msg}", @_; }, MODULE_VERSION => sub { __x # SYSTEM:MODULE_VERSION "Using module {module} version {version}.", @_; }, MODULE_END => sub { __x # SYSTEM:MODULE_END "Module {module} finished running.", @_; }, NO_NETWORK => sub { __x # SYSTEM:NO_NETWORK "Both IPv4 and IPv6 are disabled."; }, UNKNOWN_METHOD => sub { __x # SYSTEM:UNKNOWN_METHOD "Request to run unknown method {testcase} in module {module}.", @_; }, UNKNOWN_MODULE => sub { __x # SYSTEM:UNKNOWN_MODULE "Request to run {testcase} in unknown module {module}. Known modules: {module_list}.", @_; }, SKIP_IPV4_DISABLED => sub { __x # SYSTEM:SKIP_IPV4_DISABLED "IPv4 is disabled, not sending \"{rrtype}\" query to {ns}.", @_; }, SKIP_IPV6_DISABLED => sub { __x # SYSTEM:SKIP_IPV6_DISABLED "IPv6 is disabled, not sending \"{rrtype}\" query to {ns}.", @_; }, FAKE_DELEGATION_TO_SELF => sub { __x # SYSTEM:FAKE_DELEGATION_TO_SELF "Name server {ns} not adding fake delegation for domain {domain} to itself.", @_; }, FAKE_DELEGATION_IN_ZONE_NO_IP => sub { __x # SYSTEM:FAKE_DELEGATION_IN_ZONE_NO_IP "The fake delegation of domain {domain} includes an in-zone name server {nsname} " . "without mandatory glue (without IP address).", @_; }, FAKE_DELEGATION_NO_IP => sub { __x # SYSTEM:FAKE_DELEGATION_NO_IP "The fake delegation of domain {domain} includes a name server {nsname} " . "that cannot be resolved to any IP address.", @_; }, PACKET_BIG => sub { __x # SYSTEM:PACKET_BIG "Big packet size ({size}) (try with \"{command}\").", @_; }, ); ### ### Construction ### my $instance; sub new { my ( $class, %attrs ) = @_; $class->initialize( %attrs ); return $class->instance; } sub instance { my ( $class ) = @_; if ( !defined $instance ) { $class->initialize(); } return $instance; } sub initialize { my ( $class, %attrs ) = @_; if ( defined $instance ) { confess "already initialized"; } my $locale; if ( exists $attrs{locale} ) { $locale = delete $attrs{locale}; if ( !defined $locale || ref $locale ne '' ) { confess "argument 'locale' must not be a defined scalar"; } } my $obj = { _locale => $locale // _init_locale(), _all_tag_descriptions => $class->_build_all_tag_descriptions(), _last_language => _build_last_language(), }; $instance = bless $obj, $class; return; } ### ### Builder Methods ### # Get the program's underlying LC_MESSAGES and make sure it can be effectively # updated down the line. # # If the underlying LC_MESSAGES is invalid, it attempts to second guess Perl's # fallback locale. # # Side effects: # * Updates the program's underlying LC_MESSAGES to the returned value. # * Unsets LC_ALL. sub _init_locale { my $locale = setlocale( LC_MESSAGES, "" ); delete $ENV{LC_ALL}; if ( !defined $locale ) { my $language = $ENV{LANGUAGE} // ""; for my $value ( split /:/, $language ) { if ( $value ne "" && $value !~ /[.]/ ) { $value .= ".UTF-8"; } $locale = setlocale( LC_MESSAGES, $value ); if ( defined $locale ) { last; } } $locale //= "C"; } return $locale; } sub _load_data { my $self = shift; my $old_locale = $self->locale; $self->locale( 'C' ); my %data; for my $mod ( keys %{ $self->all_tag_descriptions } ) { for my $tag ( keys %{ $self->all_tag_descriptions->{$mod} } ) { $data{$mod}{$tag} = $self->_translate_tag( $mod, $tag, {} ); } } $self->locale( $old_locale ); return \%data; } sub _build_all_tag_descriptions { my ( $class ) = @_; my %all_tag_descriptions; $all_tag_descriptions{System} = \%TAG_DESCRIPTIONS; foreach my $mod ( Zonemaster::Engine::Test->modules ) { my $module = 'Zonemaster::Engine::Test::' . $mod; $all_tag_descriptions{ $mod } = $module->tag_descriptions; } return \%all_tag_descriptions; } sub _build_last_language { return $ENV{LANGUAGE} // ''; } ### ### Instance methods ### sub data { my ( $self ) = @_; if ( !exists $self->{_data} ) { $self->{_data} = $self->_load_data; } return $self->{_data}; } sub all_tag_descriptions { my ( $self ) = @_; return $self->{_all_tag_descriptions}; } sub locale { my ( $self, @args ) = @_; if ( @args ) { my $new_locale = shift @args; # On some systems gettext takes its locale from setlocale(). if ( !defined setlocale( LC_MESSAGES, $new_locale ) ) { return; } $self->_last_language( $ENV{LANGUAGE} // '' ); # On some systems gettext takes its locale from %ENV. $ENV{LC_MESSAGES} = $new_locale; # On some systems gettext refuses to switch over to another locale unless # the textdomain is reset. textdomain( 'Zonemaster-Engine' ); if ( !defined $new_locale || ref $new_locale ne '' ) { croak "locale must be a defined scalar"; } $self->{_locale} = $new_locale; } ## end if ( @args ) return $self->{_locale}; }; sub to_string { my ( $self, $entry ) = @_; return sprintf( "%7.2f %-9s %s", $entry->timestamp, $entry->level, $self->translate_tag( $entry ) ); } sub translate_tag { my ( $self, $entry ) = @_; return $self->_translate_tag( $entry->module, $entry->tag, $entry->printable_args ) // $entry->string; } sub test_case_description { my ( $self, $test_name ) = @_; my $module = $test_name; $module =~ s/\d+$//; return $self->_translate_tag( $module, uc $test_name, {} ) // $test_name; } sub _last_language { my $self = shift; if ( @_ ) { my $last_language = shift; if ( !defined $last_language || ref $last_language ne '' ) { croak "_last_language must be a defined scalar"; } $self->{_last_language} = $last_language; } return $self->{_last_language}; } sub _translate_tag { my ( $self, $module, $tag, $args ) = @_; if ( $ENV{LANGUAGE} // '' ne $self->_last_language ) { $self->locale( $self->locale ); } my $code = $self->all_tag_descriptions->{$module}{$tag}; if ( $code ) { return $code->( %{$args} ); } else { return undef; } } 1; =head1 NAME Zonemaster::Engine::Translator - translation support for Zonemaster =head1 SYNOPSIS Zonemaster::Engine::Translator->initialize( locale => 'sv_SE.UTF-8' ); my $trans = Zonemaster::Engine::Translator->instance; say $trans->to_string($entry); This is a singleton class. The instance of this class requires exclusive control over C<$ENV{LC_MESSAGES}> and the program's underlying LC_MESSAGES. At times it resets gettext's textdomain. On construction it unsets C<$ENV{LC_ALL}> and from then on it must remain unset. On systems that support C<$ENV{LANGUAGE}>, this variable overrides the locale() attribute unless the locale() attribute is set to C<"C">. =head1 ATTRIBUTES =over =item locale The locale used for localized messages. say $translator->locale(); if ( !$translator->locale( 'sv_SE.UTF-8' ) ) { say "failed to update locale"; } The value of this attribute is mirrored in C<$ENV{LC_MESSAGES}>. When writing to this attribute, a request is made to update the program's underlying LC_MESSAGES. If this request fails, the attribute value remains unchanged and an empty list is returned. As a side effect when successfully updating this attribute gettext's textdomain is reset. =item data A reference to a hash with translation data. This is unlikely to be useful to end-users. =item all_tag_descriptions =back =head1 METHODS =over =item initialize(%args) Provide initial values for the single instance of this class. Zonemaster::Engine::Translator->initialize( locale => 'sv_SE.UTF-8' ); This method must be called at most once and before the first call to instance(). =item instance() Returns the single instance of this class. my $translator = Zonemaster::Engine::Translator->instance; If initialize() has not been called prior to the first call to instance(), it is the same as if initialize() had been called without arguments. =item new(%args) Use of this method is deprecated. =over =item locale If no initial value is provided to the constructor, one is determined by calling setlocale( LC_MESSAGES, "" ). =back =item to_string($entry) Takes a L object as its argument and returns a translated string with the timestamp, level, message and arguments in the entry. =item translate_tag($entry) Takes a L object as its argument and returns a translation of its tag and arguments. =item test_case_description($testcase) Takes a string (test case ID) and returns the translated test case description. =item BUILD Internal method that's only mentioned here to placate L. =back =cut