001// Licensed under the Apache License, Version 2.0 (the "License");
002// you may not use this file except in compliance with the License.
003// You may obtain a copy of the License at
004//
005// http://www.apache.org/licenses/LICENSE-2.0
006//
007// Unless required by applicable law or agreed to in writing, software
008// distributed under the License is distributed on an "AS IS" BASIS,
009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
010// See the License for the specific language governing permissions and
011// limitations under the License.
012
013package org.apache.tapestry5.internal.services;
014
015import java.util.Collections;
016import java.util.Iterator;
017import java.util.List;
018import java.util.Map;
019import java.util.Map.Entry;
020import java.util.Objects;
021import java.util.Set;
022import java.util.stream.Collectors;
023
024import org.apache.tapestry5.commons.Messages;
025import org.apache.tapestry5.commons.Resource;
026import org.apache.tapestry5.commons.util.CaseInsensitiveMap;
027import org.apache.tapestry5.commons.util.CollectionFactory;
028import org.apache.tapestry5.commons.util.MultiKey;
029import org.apache.tapestry5.func.F;
030import org.apache.tapestry5.internal.event.InvalidationEventHubImpl;
031import org.apache.tapestry5.ioc.internal.util.URLChangeTracker;
032import org.apache.tapestry5.services.ComponentClassResolver;
033import org.apache.tapestry5.services.messages.PropertiesFileParser;
034import org.apache.tapestry5.services.pageload.ComponentResourceLocator;
035import org.apache.tapestry5.services.pageload.ComponentResourceSelector;
036import org.slf4j.Logger;
037
038/**
039 * A utility class that encapsulates all the logic for reading properties files and assembling {@link Messages} from
040 * them, in accordance with extension rules and locale. This represents code that was refactored out of
041 * {@link ComponentMessagesSourceImpl}. This class can be used as a base class, though the existing code base uses it as
042 * a utility. Composition trumps inheritance!
043 *
044 * The message catalog for a component is the combination of all appropriate properties files for the component, plus
045 * any keys inherited form base components and, ultimately, the application global message catalog. At some point we
046 * should add support for per-library message catalogs.
047 *
048 * Message catalogs are read using the UTF-8 character set. This is tricky in JDK 1.5; we read the file into memory then
049 * feed that bytestream to Properties.load().
050 */
051public class MessagesSourceImpl extends InvalidationEventHubImpl implements MessagesSource
052{
053    private final URLChangeTracker<MessagesTrackingInfo> tracker;
054
055    private final PropertiesFileParser propertiesFileParser;
056
057    private final ComponentResourceLocator resourceLocator;
058    
059    private final ComponentClassResolver componentClassResolver;
060    
061    private final boolean multipleClassLoaders;
062    
063    private final Logger logger;        
064    
065    /**
066     * Keyed on bundle id and ComponentResourceSelector.
067     */
068    private final Map<MultiKey, Messages> messagesByBundleIdAndSelector = CollectionFactory.newConcurrentMap();
069
070    /**
071     * Keyed on bundle id and ComponentResourceSelector, the cooked properties include properties inherited from less
072     * locale-specific properties files, or inherited from parent bundles.
073     */
074    private final Map<MultiKey, Map<String, String>> cookedProperties = CollectionFactory.newConcurrentMap();
075
076    /**
077     * Raw properties represent just the properties read from a specific properties file, in isolation.
078     */
079    private final Map<Resource, Map<String, String>> rawProperties = CollectionFactory.newConcurrentMap();
080
081    private final Map<String, String> emptyMap = Collections.emptyMap();
082
083    public MessagesSourceImpl(boolean productionMode, boolean multipleClassLoaders, URLChangeTracker tracker,
084                              ComponentResourceLocator resourceLocator, PropertiesFileParser propertiesFileParser,
085                              ComponentClassResolver componentClassResolver,
086                              Logger logger)
087    {
088        super(productionMode, logger);
089
090        this.tracker = tracker;
091        this.propertiesFileParser = propertiesFileParser;
092        this.resourceLocator = resourceLocator;
093        this.logger = logger;
094        this.componentClassResolver = componentClassResolver;
095        this.multipleClassLoaders = multipleClassLoaders;
096    }
097
098    public void checkForUpdates()
099    {
100        if (tracker != null)
101        {
102            final Set<MessagesTrackingInfo> changedResources = tracker.getChangedResourcesInfo();
103            if (!changedResources.isEmpty() && logger.isInfoEnabled())
104            {
105                logger.info("Changed message file(s): {}", changedResources.stream()
106                        .map(MessagesTrackingInfo::getResource)
107                        .map(Resource::toString)
108                        .collect(Collectors.joining(", ")));
109            }
110            
111            boolean applicationLevelChange = false;
112            
113            for (MessagesTrackingInfo info : changedResources) 
114            {
115                
116                final String className = info.getClassName();
117                
118                // An application-level file was changed, so we need to invalidate everything.
119                if (className == null || !multipleClassLoaders)
120                {
121                    invalidate();
122                    applicationLevelChange = true;
123                    break;
124                }
125                else
126                {
127                    
128                    final Iterator<Entry<MultiKey, Messages>> messagesByBundleIdAndSelectorIterator = 
129                            messagesByBundleIdAndSelector.entrySet().iterator();
130                    
131                    while (messagesByBundleIdAndSelectorIterator.hasNext())
132                    {
133                        final Entry<MultiKey, Messages> entry = messagesByBundleIdAndSelectorIterator.next();
134                        if (className.equals(entry.getKey().getValues()[0]))
135                        {
136                            messagesByBundleIdAndSelectorIterator.remove();
137                        }
138                    }
139                    
140                    final Iterator<Entry<MultiKey, Map<String, String>>> cookedPropertiesIterator = 
141                            cookedProperties.entrySet().iterator();
142                    
143                    while (cookedPropertiesIterator.hasNext())
144                    {
145                        final Entry<MultiKey, Map<String, String>> entry = cookedPropertiesIterator.next();
146                        if (className.equals(entry.getKey().getValues()[0]))
147                        {
148                            cookedPropertiesIterator.remove();
149                        }
150                    }
151                    
152                    final String resourceFile = info.getResource().getFile();
153                    final Iterator<Entry<Resource, Map<String, String>>> rawPropertiesIterator = rawProperties.entrySet().iterator();
154                    while (rawPropertiesIterator.hasNext())
155                    {
156                        final Entry<Resource, Map<String, String>> entry = rawPropertiesIterator.next();
157                        if (resourceFile.equals(entry.getKey().getFile()))
158                        {
159                            rawPropertiesIterator.remove();
160                        }
161                    }
162                    
163                }
164            }
165            
166            if (!changedResources.isEmpty() && !applicationLevelChange)
167            {
168                fireInvalidationEvent(changedResources.stream()
169                        .filter(Objects::nonNull)
170                        .map(ClassNameHolder::getClassName)
171                        .filter(Objects::nonNull)
172                        .collect(Collectors.toList()));
173            }
174        }
175    }
176
177    public void invalidate()
178    {
179        messagesByBundleIdAndSelector.clear();
180        cookedProperties.clear();
181        rawProperties.clear();
182
183        tracker.clear();
184
185        fireInvalidationEvent();
186    }
187
188    public Messages getMessages(MessagesBundle bundle, ComponentResourceSelector selector)
189    {
190        MultiKey key = new MultiKey(bundle.getId(), selector);
191
192        Messages result = messagesByBundleIdAndSelector.get(key);
193
194        if (result == null)
195        {
196            result = buildMessages(bundle, selector);
197            messagesByBundleIdAndSelector.put(key, result);
198        }
199
200        return result;
201    }
202
203    private Messages buildMessages(MessagesBundle bundle, ComponentResourceSelector selector)
204    {
205        Map<String, String> properties = findBundleProperties(bundle, selector);
206
207        return new MapMessages(selector.locale, properties);
208    }
209
210    /**
211     * Assembles a set of properties appropriate for the bundle in question, and the desired locale. The properties
212     * reflect the properties of the bundles' parent (if any) for the locale, overalyed with any properties defined for
213     * this bundle and its locale.
214     */
215    private Map<String, String> findBundleProperties(MessagesBundle bundle, ComponentResourceSelector selector)
216    {
217        if (bundle == null)
218            return emptyMap;
219
220        MultiKey key = new MultiKey(bundle.getId(), selector);
221
222        Map<String, String> existing = cookedProperties.get(key);
223
224        if (existing != null)
225            return existing;
226
227        // What would be cool is if we could maintain a cache of bundle id + locale -->
228        // Resource. That would optimize quite a bit of this; may need to use an alternative to
229        // LocalizedNameGenerator.
230
231        Resource propertiesResource = bundle.getBaseResource().withExtension("properties");
232
233        List<Resource> localizations = resourceLocator.locateMessageCatalog(propertiesResource, selector);
234
235        // Localizations are now in least-specific to most-specific order.
236
237        Map<String, String> previous = findBundleProperties(bundle.getParent(), selector);
238
239        for (Resource localization : F.flow(localizations).reverse())
240        {
241            Map<String, String> rawProperties = getRawProperties(localization, bundle);
242
243            // Would be nice to write into the cookedProperties cache here,
244            // but we can't because we don't know the selector part of the MultiKey.
245
246            previous = extend(previous, rawProperties);
247        }
248
249        cookedProperties.put(key, previous);
250
251        return previous;
252    }
253
254    /**
255     * Returns a new map consisting of all the properties in previous overlayed with all the properties in
256     * rawProperties. If rawProperties is empty, returns just the base map.
257     */
258    private Map<String, String> extend(Map<String, String> base, Map<String, String> rawProperties)
259    {
260        if (rawProperties.isEmpty())
261            return base;
262
263        // Make a copy of the base Map
264
265        Map<String, String> result = new CaseInsensitiveMap<String>(base);
266
267        // Add or overwrite properties to the copy
268
269        result.putAll(rawProperties);
270
271        return result;
272    }
273
274    private Map<String, String> getRawProperties(Resource localization, MessagesBundle bundle)
275    {
276        Map<String, String> result = rawProperties.get(localization);
277
278        if (result == null)
279        {
280            result = readProperties(localization, bundle);
281
282            rawProperties.put(localization, result);
283        }
284
285        return result;
286    }
287
288    /**
289     * Creates and returns a new map that contains properties read from the properties file.
290     * @param bundle 
291     */
292    private Map<String, String> readProperties(Resource resource, MessagesBundle bundle)
293    {
294        if (!resource.exists())
295            return emptyMap;
296
297        if (tracker != null)
298        {
299            MessagesTrackingInfo info = new MessagesTrackingInfo(
300                    resource, bundle != null ? bundle.getId() : bundle, getClassName(bundle));
301            tracker.add(resource.toURL(), info);
302        }
303
304        try
305        {
306            return propertiesFileParser.parsePropertiesFile(resource);
307        } catch (Exception ex)
308        {
309            throw new RuntimeException(String.format("Unable to read message catalog from %s: %s", resource, ex), ex);
310        }
311    }
312
313    private String getClassName(MessagesBundle bundle)
314    {
315        String className = null;
316        if (bundle != null && bundle.getBaseResource().getPath() != null)
317        {
318            final String path = bundle.getBaseResource().getPath();
319            if (path.endsWith(".class"))
320            {
321                className = path.replace('/', '.').replace(".class", "");
322                if (!componentClassResolver.isPage(className)) 
323                {
324                    className = null;
325                }
326            }
327        }
328        return className;
329    }
330
331}