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.i18nsystem.util.LocaleUtils;
38  import info.magnolia.objectfactory.Components;
39  import info.magnolia.resourceloader.Resource;
40  import info.magnolia.resourceloader.ResourceChangeHandler;
41  import info.magnolia.resourceloader.ResourceOrigin;
42  import info.magnolia.resourceloader.ResourceOriginChange;
43  import info.magnolia.resourceloader.util.FileResourceCollectorVisitor;
44  import info.magnolia.resourceloader.util.Functions;
45  
46  import java.io.IOException;
47  import java.io.InputStream;
48  import java.io.InputStreamReader;
49  import java.io.Reader;
50  import java.util.Collection;
51  import java.util.Collections;
52  import java.util.HashMap;
53  import java.util.Locale;
54  import java.util.Map;
55  import java.util.Properties;
56  import java.util.Set;
57  import java.util.TreeMap;
58  import java.util.TreeSet;
59  import java.util.function.Predicate;
60  
61  import javax.inject.Inject;
62  import javax.inject.Singleton;
63  
64  import org.apache.commons.io.input.BOMInputStream;
65  import org.jsoup.Jsoup;
66  import org.jsoup.nodes.Document;
67  import org.jsoup.safety.Cleaner;
68  import org.jsoup.safety.Whitelist;
69  import org.slf4j.Logger;
70  import org.slf4j.LoggerFactory;
71  
72  /**
73   * Loads global message bundles from <code>{@value #MGNL_I18N_PATH}</code> and <code>{@value #OLD_I18N_PATH}</code>.
74   */
75  @Singleton
76  public class DefaultMessageBundlesLoader {
77  
78      public static final String MGNL_I18N_PATH = "^/[^/]+/i18n/";
79      public static final String MGNL_I18N_PROPERTIES = MGNL_I18N_PATH + ".*\\.properties$";
80  
81      private static final String OLD_I18N_PATH = "^/mgnl-i18n/";
82      private static final String OLD_I18N_PROPERTIES = OLD_I18N_PATH + ".*\\.properties$";
83  
84      protected static final Predicate<Resource> DIRECTORY_PREDICATE = dir -> true;
85      protected static final Predicate<Resource> RESOURCE_PREDICATE = Functions.pathMatches(OLD_I18N_PROPERTIES)
86              .or(Functions.pathMatches(MGNL_I18N_PROPERTIES));
87  
88      private static final Logger log = LoggerFactory.getLogger(DefaultMessageBundlesLoader.class);
89      private static final Cleaner CLEANER = new Cleaner(Whitelist.basic());
90  
91      private Map<Locale, Properties> messages = Collections.unmodifiableMap(new HashMap<>());
92  
93      // this map collects all duplicates  based on key pattern <key>_<locale>_<bundle-url>
94      private final Map<String, DuplicateEntry> duplicateEntriesMap = new HashMap<>();
95  
96      @Inject
97      public DefaultMessageBundlesLoader(final ResourceOrigin resourceOrigin) {
98          loadMessages(resourceOrigin, newVisitor());
99  
100         // Previously this ResourceChangeHandler registration was only added when the ModulesStartedEvent was triggered
101         // after all modules were successfully started, see info.magnolia.module.ModuleManagerImpl#startModules().
102         // Because this component is instantiated at a much later time, the ModulesStartedEvent might already been
103         // issued which would result in not adding any ResourceChangeHandler at all. We therefore simply register the
104         // handler directly.
105 
106         log.info("Starting monitoring of {} to load translation files", resourceOrigin);
107         resourceOrigin.registerResourceChangeHandler(new ResourceChangeHandler() {
108             @Override
109             public void onResourceChanged(ResourceOriginChange change) {
110 
111                 // Provided FileResourceCollectorVisitor (see #newVisitor()) will visit both new i18n and legacy i18n
112                 // resources however we only (re-) load messages when a ResourceOriginChange is detected from the new
113                 // properties. This is due to the fact that old / legacy i18n properties file changes will not trigger
114                 // those events.
115 
116                 if (change.getRelatedResourcePath().matches(MGNL_I18N_PROPERTIES)) {
117                     loadMessages(resourceOrigin, newVisitor());
118                 }
119             }
120         });
121     }
122 
123     /**
124      * @deprecated since 5.4.5. Use {@link #DefaultMessageBundlesLoader(info.magnolia.resourceloader.ResourceOrigin)} instead.
125      */
126     @Deprecated
127     public DefaultMessageBundlesLoader() {
128         this(Components.getComponent(ResourceOrigin.class));
129     }
130 
131     Map<Locale, Properties> getMessages() {
132         return messages;
133     }
134 
135     private FileResourceCollectorVisitor newVisitor() {
136         return FileResourceCollectorVisitor.on(DIRECTORY_PREDICATE, RESOURCE_PREDICATE);
137     }
138 
139     private void loadMessages(ResourceOrigin resourceOrigin, FileResourceCollectorVisitor visitor) {
140         final Map<Locale, Properties> newMessages = new HashMap<>();
141 
142         resourceOrigin.traverseWith(visitor);
143         final Collection<Resource> collected = visitor.getCollectedResources();
144         for (Resource langFile : collected) {
145             final String fileName = langFile.getName();
146             final Locale locale = resolveLocale(fileName);
147             loadResourcesInPropertiesMap(newMessages, langFile, locale);
148         }
149 
150         this.messages = Collections.unmodifiableMap(newMessages);
151 
152         // Log the duplicates AFTER all the resource files have been loaded.
153         logDuplicates();
154     }
155 
156     /**
157      * Loads a {@link Properties properties resource file} into given {@link Map} and checks for duplicate entries.
158      *
159      * <p>Since we use ResourceOrigin, we can't detect duplicates in classpath resources anymore
160      * (see <a href="https://jira.magnolia-cms.com/browse/MAGNOLIA-6376">MAGNOLIA-6376</a>).</p>
161      *
162      * <p>A properties file might be associated with multiple actual resources in the classpath, e.g. when different
163      * modules declare a same name file under <code>/mgnl-i18n</code>.<br>
164      * This method ensures that in such a case all resources are loaded. Moreover, should an existing properties file
165      * already contains keys found in the one currently being loaded, the method will output a WARN message and will
166      * overwrite the existing key with the new one.</p>
167      */
168     private void loadResourcesInPropertiesMap(final Map<Locale, Properties> newMessages, Resource propertiesFile, Locale locale) {
169         try (InputStream in = propertiesFile.openStream()) {
170             log.debug("Loading properties file at [{}] with locale [{}]...", propertiesFile, locale);
171 
172             // Use UnicodeReader when available https://code.google.com/p/guava-libraries/issues/detail?id=345
173             final Reader inStream = new InputStreamReader(new BOMInputStream(in), "UTF-8");
174 
175             final Properties properties = new Properties();
176             properties.load(inStream);
177             Properties existingProperties = newMessages.get(locale);
178 
179             if (existingProperties != null) {
180                 checkForDuplicates(existingProperties, properties, locale, propertiesFile);
181                 // this locale already exists in our bundle, add the new ones
182                 log.debug("Adding properties to already existing ones under {} locale", locale);
183                 properties.putAll(existingProperties);
184             }
185 
186             for (Map.Entry<Object, Object> property : properties.entrySet()) {
187                 String propertyValue = property.getValue().toString();
188                 Document document = Jsoup.parseBodyFragment(propertyValue, "");
189                 if (!CLEANER.isValid(document)) {
190                     properties.replace(property.getKey(), CLEANER.clean(document).body().html());
191                 }
192             }
193 
194             newMessages.put(locale, properties);
195         } catch (IOException e) {
196             log.warn("An IO error occurred while trying to read properties file at [{}]", propertiesFile, e);
197         }
198     }
199 
200     private void checkForDuplicates(Properties existingProperties, Properties newProperties, Locale locale, Resource resource) {
201         for (String key : newProperties.stringPropertyNames()) {
202             if (existingProperties.containsKey(key)) {
203                 DuplicateEntry duplicateEntry = new DuplicateEntry(resource, locale, key);
204                 duplicateEntriesMap.put(duplicateEntry.getKeyWithLocaleAndUrl(), duplicateEntry);
205             }
206         }
207     }
208 
209     private void logDuplicates() {
210         // this map contains all the duplicates based on key pattern <key>_<locale>
211         Map<String, DuplicateEntry> perLocaleDuplicates = new TreeMap<>();
212         // this map contains all the duplicates based on key pattern <key>
213         Map<String, DuplicateEntry> perKeyDuplicates = new TreeMap<>();
214         Set<String> urlsSet = new TreeSet<>();
215 
216         for (DuplicateEntry duplicateEntry : duplicateEntriesMap.values()) {
217             perLocaleDuplicates.put(duplicateEntry.getKeyWithLocale(), duplicateEntry);
218             perKeyDuplicates.put(duplicateEntry.getKey(), duplicateEntry);
219             urlsSet.add(duplicateEntry.getResource().toString());
220         }
221 
222         if (duplicateEntriesMap.size() > 0) {
223             StringBuilder logMsg = new StringBuilder("\n");
224             logMsg.append("------------------------------------\n");
225             logMsg.append("Duplicated keys found while loading message bundles from ./mgnl-i18n :\n");
226             logMsg.append("------------------------------------\n");
227             logMsg.append("Number of duplicates based on key pattern <key>_<locale>_<bundle-url>: ").append(duplicateEntriesMap.size()).append("\n");
228             logMsg.append("Number of duplicates based on key pattern <key>_<locale>: ").append(perLocaleDuplicates.size()).append("\n");
229             logMsg.append("Number of duplicates based on key pattern <key>: ").append(perKeyDuplicates.size()).append("\n");
230             logMsg.append("To get more details concerning the keys, raise the log level to 'DEBUG' for ").append(this.getClass().getName()).append(".\n");
231             logMsg.append("If you encounter a large number of duplicates, it's possible that you are running in a development environment where you have multiple copies of the web-apps in the overlays folder of your web-app.\n");
232             logMsg.append("URLs of the affected files creating duplicate entries:\n");
233             for (String url : urlsSet) {
234                 logMsg.append(url).append("\n");
235             }
236             logMsg.append("------------------------------------");
237             log.info(logMsg.toString());
238 
239             // logging all the duplicated keys (based on key pattern <key>_<locale>) only in debug level.
240             for (DuplicateEntry duplicateEntry : perLocaleDuplicates.values()) {
241                 log.debug("Duplicate key found: [{}]; for locale [{}]; in resource [{}]", duplicateEntry.getKey(), duplicateEntry.getLocale(), duplicateEntry.getResource());
242             }
243         }
244 
245         duplicateEntriesMap.clear();
246     }
247 
248     /**
249      * @see info.magnolia.i18nsystem.util.LocaleUtils#parseFromFilename(String, java.util.Locale)
250      */
251     protected Locale resolveLocale(final String fileName) {
252         return LocaleUtils.parseFromFilename(fileName, getFallbackLocale());
253     }
254 
255     // TODO - move this to a SystemLocalesManager component
256     private Locale getFallbackLocale() {
257         return MessagesManager.getInstance().getDefaultLocale();
258     }
259 
260     // An inner class to monitor the duplicates.
261     private class DuplicateEntry {
262         private Resource resource;
263         private Locale locale;
264         private String key;
265         private String keyWithLocale;
266         private String keyWithLocaleAndUrl;
267 
268         private DuplicateEntry(Resource resource, Locale locale, String key) {
269             this.resource = resource;
270             this.locale = locale;
271             this.key = key;
272             this.keyWithLocale = this.key + "_" + locale.toString();
273             this.keyWithLocaleAndUrl = this.keyWithLocale + "_" + resource.getPath();
274         }
275 
276         public Resource getResource() {
277             return resource;
278         }
279 
280         private String getKeyWithLocale() {
281             return keyWithLocale;
282         }
283 
284         private String getKeyWithLocaleAndUrl() {
285             return keyWithLocaleAndUrl;
286         }
287 
288         private Locale getLocale() {
289             return locale;
290         }
291 
292         private String getKey() {
293             return key;
294         }
295 
296     }
297 
298 }