View Javadoc
1   /**
2    * This file Copyright (c) 2013-2018 Magnolia International
3    * Ltd.  (http://www.magnolia-cms.com). All rights reserved.
4    *
5    *
6    * This file is dual-licensed under both the Magnolia
7    * Network Agreement and the GNU General Public License.
8    * You may elect to use one or the other of these licenses.
9    *
10   * This file is distributed in the hope that it will be
11   * useful, but AS-IS and WITHOUT ANY WARRANTY; without even the
12   * implied warranty of MERCHANTABILITY or FITNESS FOR A
13   * PARTICULAR PURPOSE, TITLE, or NONINFRINGEMENT.
14   * Redistribution, except as permitted by whichever of the GPL
15   * or MNA you select, is prohibited.
16   *
17   * 1. For the GPL license (GPL), you can redistribute and/or
18   * modify this file under the terms of the GNU General
19   * Public License, Version 3, as published by the Free Software
20   * Foundation.  You should have received a copy of the GNU
21   * General Public License, Version 3 along with this program;
22   * if not, write to the Free Software Foundation, Inc., 51
23   * Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
24   *
25   * 2. For the Magnolia Network Agreement (MNA), this file
26   * and the accompanying materials are made available under the
27   * terms of the MNA which accompanies this distribution, and
28   * is available at http://www.magnolia-cms.com/mna.html
29   *
30   * Any modifications to this file must keep this entire header
31   * intact.
32   *
33   */
34  package info.magnolia.i18nsystem;
35  
36  import info.magnolia.cms.i18n.MessagesManager;
37  import info.magnolia.event.EventBus;
38  import info.magnolia.event.SystemEventBus;
39  import info.magnolia.i18nsystem.module.I18nModule;
40  import info.magnolia.objectfactory.ComponentProvider;
41  import info.magnolia.objectfactory.Components;
42  import info.magnolia.resourceloader.ResourceOrigin;
43  
44  import java.util.Arrays;
45  import java.util.Locale;
46  import java.util.Optional;
47  import java.util.Properties;
48  
49  import javax.inject.Inject;
50  import javax.inject.Named;
51  import javax.inject.Provider;
52  import javax.inject.Singleton;
53  
54  import org.apache.commons.lang3.StringUtils;
55  import org.jsoup.Jsoup;
56  import org.jsoup.nodes.Document;
57  import org.jsoup.safety.Cleaner;
58  import org.jsoup.safety.Whitelist;
59  import org.slf4j.Logger;
60  import org.slf4j.LoggerFactory;
61  
62  /**
63   * A TranslationService which relies on a "global" message bundle, as loaded per {@link DefaultMessageBundlesLoader}.
64   * Each given key is looked up in the given locale, then in less specific locales, then in the fallback locale.
65   * That is, if [a, b, c] are passed as keys, with country-specific Locale such as de_CH, and "b" is defined in the de_CH
66   * message bundle, it will be returned, even if "a" is defined in the less specific "de" Locale.
67   *
68   * When a basename is explicitly specified, it however uses {@link MessagesManager} to look the keys up, falls back
69   * to the default bundle of MessagesManager, then finally falls back on its own global message bundle.
70   */
71  @Singleton
72  public class TranslationServiceImpl implements TranslationService {
73      private static final Logger log = LoggerFactory.getLogger(TranslationServiceImpl.class);
74      private static final Cleaner CLEANER = new Cleaner(Whitelist.basic());
75  
76      private final Provider<I18nModule> i18nModuleProvider;
77      private final Provider<DefaultMessageBundlesLoader> defaultMessageBundlesLoaderProvider;
78  
79      @Inject
80      public TranslationServiceImpl(final Provider<I18nModule> i18nModuleProvider, final Provider<DefaultMessageBundlesLoader> defaultMessageBundlesLoaderProvider) {
81          this.i18nModuleProvider = i18nModuleProvider;
82          this.defaultMessageBundlesLoaderProvider = defaultMessageBundlesLoaderProvider;
83      }
84  
85      /**
86       * @deprecated since 5.5.2. Use {@link #TranslationServiceImpl(javax.inject.Provider, javax.inject.Provider<DefaultMessageBundlesLoader>)} instead.
87       */
88      @Deprecated
89      public TranslationServiceImpl(final Provider<I18nModule> i18nModuleProvider, final ComponentProvider componentProvider, final ResourceOrigin resourceOrigin, @Named(SystemEventBus.NAME) EventBus systemEventBus) {
90          this(i18nModuleProvider, new Provider<DefaultMessageBundlesLoader>() {
91              @Override
92              public DefaultMessageBundlesLoader get() {
93                  return Components.getComponent(DefaultMessageBundlesLoader.class);
94              }
95          });
96      }
97  
98      /**
99       * @deprecated since 5.4.5. Use {@link #TranslationServiceImpl(javax.inject.Provider, javax.inject.Provider<DefaultMessageBundlesLoader>)} instead.
100      */
101     @Deprecated
102     public TranslationServiceImpl(Provider<I18nModule> i18nModuleProvider) {
103         this(i18nModuleProvider, new Provider<DefaultMessageBundlesLoader>() {
104             @Override
105             public DefaultMessageBundlesLoader get() {
106                 return Components.getComponent(DefaultMessageBundlesLoader.class);
107             }
108         });
109     }
110 
111     /**
112      * @deprecated since 5.4.4. Use {@link #TranslationServiceImpl(javax.inject.Provider, javax.inject.Provider<DefaultMessageBundlesLoader>)} instead.
113      */
114     @Deprecated
115     public TranslationServiceImpl() {
116         this(new Provider<I18nModule>() {
117             @Override
118             public I18nModule get() {
119                 return Components.getComponent(I18nModule.class);
120             }
121         }, new Provider<DefaultMessageBundlesLoader>() {
122             @Override
123             public DefaultMessageBundlesLoader get() {
124                 return Components.getComponent(DefaultMessageBundlesLoader.class);
125             }
126         });
127     }
128 
129     /**
130      * @deprecated since 5.5.2. Use {@link #defaultMessageBundlesLoaderProvider} directly or setup message bundles in {@link DefaultMessageBundlesLoader}.
131      */
132     @Deprecated
133     protected DefaultMessageBundlesLoader setupMessageBundles() {
134         return defaultMessageBundlesLoaderProvider.get();
135     }
136 
137     @Override
138     public String translate(LocaleProvider localeProvider, String[] keys) {
139         return translate(localeProvider, null, keys);
140     }
141 
142     @Override
143     public String translate(LocaleProvider localeProvider, String[] keys, String fallback) {
144         return translate(localeProvider.get(), null, keys, fallback);
145     }
146 
147     @Override
148     public String translate(LocaleProvider localeProvider, String basename, String[] keys) {
149         final Locale locale = localeProvider.getLocale();
150         return translate(locale, basename, keys, I18nText.NO_FALLBACK);
151     }
152 
153     private String translate(Locale locale, String basename, String[] keys, String fallback) {
154         if (locale == null) {
155             throw new IllegalArgumentException("Locale can't be null");
156         }
157         if (keys == null || keys.length < 1) {
158             throw new IllegalArgumentException("Keys can't be null or empty");
159         }
160 
161         if (basename != null) {
162             log.debug("Got an explicit basename ({}) for keys {}", basename, Arrays.asList(keys));
163         }
164 
165         final String message = lookUpKeyUntilFound(keys, locale, basename);
166         if (message != null) {
167             return message;
168         } else {
169             return I18nText.NO_FALLBACK.equals(fallback) ?
170                     handleUnknownKey(locale, basename, keys) :
171                     Optional.ofNullable(lookUpKeyUntilFound(new String[]{fallback}, locale, null)).orElse(fallback);
172         }
173     }
174 
175     private String lookUpKeyUntilFound(final String[] keys, final Locale locale, final String basename) {
176         String message = null;
177 
178         if (StringUtils.isNotBlank(basename)) {
179             log.debug("Looking up key [{}] with basename [{}] and Locale [{}] - legacy method", keys[0], basename, locale);
180             message = legacyLookup(locale, basename, keys[0]);
181             if (message != null) {
182                 return message + (isDebug() ? this.addDebugInfo(keys, keys[0], locale, basename) : StringUtils.EMPTY);
183             }
184         }
185 
186         if (message == null) {
187             log.debug("Looking up in global i18n message bundle with key [{}] and Locale [{}]", Arrays.asList(keys), locale);
188             message = doGetMessage(keys, locale);
189         }
190 
191         if (message == null) {
192             final String country = locale.getCountry();
193             if (country != null) {
194                 final Locale newLocale = new Locale(locale.getLanguage(), country);
195                 message = doGetMessage(keys, newLocale);
196             }
197         }
198 
199         if (message == null) {
200             final Locale newLocale = new Locale(locale.getLanguage());
201             message = doGetMessage(keys, newLocale);
202         }
203 
204         if (message == null) {
205             message = doGetMessage(keys, getFallbackLocale());
206         }
207 
208         return message;
209     }
210 
211     private String addDebugInfo(final String[] keys, String currentlyUsedKey, final Locale locale, final String basename) {
212         return "\n" + Arrays.asList(keys).toString()
213                 .replaceFirst("(?s)" + currentlyUsedKey + "(?!.*?" + currentlyUsedKey + ")", ">" + currentlyUsedKey + "<") //replace last occurrence
214                 + locale.getClass().getSimpleName() + ":" + locale + (basename == null ? StringUtils.EMPTY : ",Using legacy i18n basename:" + basename); //: is used because = is stripped in area/components bars in page editor
215     }
216 
217     protected String handleUnknownKey(Locale locale, String basename, String[] keys) {
218         // TODO - this method could be context dependent, or delegate to a configured component. In dev mode, for instance, we could at least print this out, or return the key, while in production this is neither useful nor needed.
219         log.debug("No translation found for any of {} with locale {} and basename {}", keys, locale, basename != null ? basename : "<unspecified>");
220         return keys[0] + (isDebug() ? this.addDebugInfo(keys, null, locale, basename) : StringUtils.EMPTY);
221     }
222 
223     /**
224      * Looks up a particular key using the given basename and Locale, using the legacy MessagesManager.
225      */
226     private String legacyLookup(Locale locale, String basename, String key) {
227         // Note that this internally chains the given locale with the fallback locale (as known by the MessagesManager), so if a key is known in english, it will be returned in english before we lookup in the default bundles
228         String message = MessagesManager.getMessages(basename, locale).get(key);
229         if (legacyMessageNotFound(message)) {
230             message = MessagesManager.getMessages(MessagesManager.DEFAULT_BASENAME, locale).get(key);
231         }
232         if (legacyMessageNotFound(message)) {
233             // Let's not get any of the legacy "???" markers out of here
234             return null;
235         } else {
236             Document document = Jsoup.parseBodyFragment(message, "");
237             if (!CLEANER.isValid(document)) {
238                 return CLEANER.clean(document).body().html();
239             } else {
240                 return message;
241             }
242         }
243     }
244 
245     private boolean legacyMessageNotFound(final String message) {
246         return message == null || message.startsWith("???");
247     }
248 
249     private String doGetMessage(String[] keys, Locale locale) {
250         final Properties properties = defaultMessageBundlesLoaderProvider.get().getMessages().get(locale);
251         if (properties != null) {
252             for (String key : keys) {
253                 if (key == null) {
254                     // Keys can sometimes be null (e.g when using NullKeyGenerator and the undecorated or configured text is null)
255                     continue;
256                 }
257                 final String message = properties.getProperty(key);
258                 if (message != null) {
259                     return message + (isDebug() ? this.addDebugInfo(keys, key, locale, null) : StringUtils.EMPTY);
260                 }
261             }
262         }
263         return null;
264     }
265 
266     private boolean isDebug() {
267         try {
268             return i18nModuleProvider.get() != null && i18nModuleProvider.get().isDebug();
269         } catch (RuntimeException exception) {
270             // Under certain circumstances the provider will throw this exception (e.g. when module is not loaded yet).
271             // We are catching it as we only need to know whether we want debugging on or not even though the javadoc
272             // of javax.inject.Provider.get() suggests to not handle these.
273             return false;
274         }
275     }
276 
277     // TODO - move this to a SystemLocalesManager component
278     private Locale getFallbackLocale() {
279         return MessagesManager.getInstance().getDefaultLocale();
280     }
281 
282     /**
283      * @deprecated since 5.5.2. Reload message bundles in {@link DefaultMessageBundlesLoader} directly.
284      */
285     @Deprecated
286     @Override
287     public void reloadMessageBundles() {
288         log.warn("Not reloading message bundles. Please make sure to update [{}] instead.", DefaultMessageBundlesLoader.class.getName());
289     }
290 
291 }