View Javadoc
1   /*
2    * Copyright (c) 2023. Roland T. Lichti, Kaiserpfalz EDV-Service.
3    *
4    * This program is free software: you can redistribute it and/or modify
5    * it under the terms of the GNU General Public License as published by
6    * the Free Software Foundation, either version 3 of the License, or
7    * (at your option) any later version.
8    *
9    * This program is distributed in the hope that it will be useful,
10   * but WITHOUT ANY WARRANTY; without even the implied warranty of
11   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12   * GNU General Public License for more details.
13   *
14   * You should have received a copy of the GNU General Public License
15   * along with this program.  If not, see <https://www.gnu.org/licenses/>.
16   */
17  
18  package de.kaiserpfalzedv.commons.core.i18n;
19  
20  import java.io.BufferedReader;
21  import java.io.IOException;
22  import java.io.InputStreamReader;
23  import java.net.URL;
24  import java.nio.charset.StandardCharsets;
25  import java.text.MessageFormat;
26  import java.util.HashMap;
27  import java.util.List;
28  import java.util.Locale;
29  import java.util.MissingResourceException;
30  import java.util.PropertyResourceBundle;
31  import java.util.ResourceBundle;
32  import java.util.stream.Collectors;
33  
34  import de.kaiserpfalzedv.commons.api.i18n.MessageSource;
35  import de.kaiserpfalzedv.commons.api.i18n.NoSuchMessageException;
36  import de.kaiserpfalzedv.commons.api.i18n.Translator;
37  import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
38  import lombok.Getter;
39  import lombok.Setter;
40  import lombok.extern.slf4j.Slf4j;
41  
42  /**
43   * Translator -- Provides a nice way to read translations from Resource bundles.
44   *
45   * @author klenkes74 {@literal <rlichti@kaiserpfalz-edv.de>}
46   * @since 0.1.0  2021-03-27
47   */
48  @Slf4j
49  public class ResourceBundleTranslator implements Translator, MessageSource {
50      private static final long serialVersionUID = 0L;
51  
52      /**
53       * The languages this class provides. To enable testing, a setter is provided. Normally it will be configured via
54       * property with a default of "de,en,fr,nl,es,it".
55       */
56      @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "lombok generated setter")
57      @Setter
58      @Getter
59      private List<String> configuredLanguages = List.of("de","en","fr","nl","es","it");
60  
61      /**
62       * Default bundle to use when no other bundle is selected.
63       */
64      private final String defaultBundle;
65  
66      /**
67       * The default locale (used when no locale is specified in the translation call). To enable testing, a setter is
68       * provided. Normally it will be configured via property "default-locale" with a default of "de".
69       */
70      @Setter
71      @Getter
72      private String defaultLocale = "de";
73  
74      private final transient HashMap<String, HashMap<Locale, ResourceBundle>> bundles = new HashMap<>();
75  
76  
77      public ResourceBundleTranslator() {
78          this("messages");
79      }
80  
81      public ResourceBundleTranslator(final String defaultBundle) {
82          this.defaultBundle = defaultBundle;
83      }
84  
85  
86      @SuppressWarnings("RedundantThrows")
87      @Override
88      public String getMessage(final String key, final Object[] params, final Locale locale) throws NoSuchMessageException {
89          return this.getTranslation(key, locale, params);
90      }
91  
92      @Override
93      public String getTranslation(final String key, final Locale locale, final Object... arguments) {
94          return this.getTranslation(this.defaultBundle, key, locale, arguments);
95      }
96  
97      @Override
98      public String getTranslation(final Object bundleObject, final String key, final Locale locale, final Object... arguments) {
99          final String bundleName = bundleObject.getClass().getCanonicalName()
100                 .replace(".", "/")
101                 .replace("_Subclass", ""); // get around for lombok or both or so. :-(
102 
103         return this.getTranslation(bundleName, key, locale, arguments);
104     }
105 
106     @SuppressFBWarnings(value = "DCN_NULLPOINTER_EXCEPTION", justification = "It's the most elegant way.")
107     @Override
108     public String getTranslation(final String bundleName, final String key, final Locale locale, final Object... arguments) {
109         this.loadBundle(bundleName, locale);
110         try {
111             final String pattern = this.bundles.get(bundleName).get(locale).getString(key);
112             final MessageFormat format = new MessageFormat(pattern, locale);
113             return format.format(arguments);
114         } catch (NullPointerException | MissingResourceException ex) {
115             log.warn(
116                     "Translation failed. bundle={}, locale={}, key={}",
117                     bundleName, locale, key
118             );
119             return "!" + key;
120         }
121     }
122 
123 
124 
125     /**
126      * Loads the bundle into the cache.
127      *
128      * @param bundleName The base filename for the translation bundle.
129      * @param locale     The locale to load the bundle for.
130      */
131     @SuppressFBWarnings(value = "DCN_NULLPOINTER_EXCEPTION", justification = "It's the most elegant way.")
132     private void loadBundle(final String bundleName, Locale locale) {
133         if (!this.bundles.containsKey(bundleName)) {
134             log.debug("Adding bundle. baseName='{}'", bundleName);
135 
136             this.bundles.put(bundleName, new HashMap<>());
137         }
138 
139         if (locale == null) {
140             locale = Locale.forLanguageTag(this.defaultLocale);
141         }
142 
143         if (!this.bundles.get(bundleName).containsKey(locale)) {
144             log.info("Loading bundle. baseName='{}', locale='{}'", bundleName, locale.getDisplayName());
145 
146             ResourceBundle bundle;
147             try {
148                 bundle = ResourceBundle.getBundle(bundleName, locale, new UnicodeResourceBundleControl());
149             } catch (NullPointerException | MissingResourceException e) {
150                 final Locale l = Locale.forLanguageTag(locale.getLanguage());
151 
152                 log.warn("Translator did not find the wanted locale for the bundle. bundle={}, locale={}, orig.locale={}, error='{}'",
153                         bundleName, l, locale, e.getMessage());
154                 try {
155                     bundle = ResourceBundle.getBundle(bundleName, l, new UnicodeResourceBundleControl());
156                 } catch (NullPointerException | MissingResourceException e1) {
157                     log.warn("Translator did not find the wanted bundle. Using default bundle. bundle={}, error='{}'", bundleName, e1.getMessage());
158 
159                     try {
160                         bundle = ResourceBundle.getBundle(this.defaultBundle, Locale.forLanguageTag(this.defaultLocale),
161                                 new UnicodeResourceBundleControl());
162                     } catch (NullPointerException | MissingResourceException e2) {
163                         log.error("Resource bundle can't be read. error='{}'", e2.getMessage());
164 
165                         return;
166                     }
167                 }
168             }
169             this.bundles.get(bundleName).put(locale, bundle);
170         }
171     }
172 
173     @Override
174     public void close() {
175         log.info("Closing all bundles.");
176         this.bundles.clear();
177     }
178 
179     @Override
180     public List<Locale> getProvidedLocales() {
181         return this.configuredLanguages.stream()
182                 .map(Locale::forLanguageTag)
183                 .filter(d -> {log.trace("Mapped language. locale={}", d); return true;})
184                 .collect(Collectors.toList());
185     }
186 
187     /**
188      * @author peholmst
189      * @since 0.1.0
190      */
191     private static class UnicodeResourceBundleControl extends ResourceBundle.Control {
192         @Override
193         public ResourceBundle newBundle(
194                 final String baseName,
195                 final Locale locale,
196                 final String format,
197                 final ClassLoader loader,
198                 final boolean reload
199         ) throws IOException {
200             final ClassLoader used = Thread.currentThread().getContextClassLoader();
201 
202             log.debug("Classloader will be ignored. used={}, ignored={}", used, loader);
203 
204             final String bundleName = this.toBundleName(baseName, locale);
205             final String resourceName = this.toResourceName(bundleName, "properties");
206             final URL resourceURL = used.getResource(resourceName);
207             if (resourceURL == null)
208                 return null;
209 
210             try (BufferedReader in = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
211                 return new PropertyResourceBundle(in);
212             }
213         }
214     }
215 }