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