blob: d0a45e8590f784e1f852a293b77ef5c73857f0dc [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.sling.i18n.impl;
import static org.apache.sling.i18n.impl.JcrResourceBundle.PROP_BASENAME;
import static org.apache.sling.i18n.impl.JcrResourceBundle.PROP_LANGUAGE;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.Dictionary;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import org.apache.felix.scr.annotations.Component;
import org.apache.felix.scr.annotations.Property;
import org.apache.felix.scr.annotations.Reference;
import org.apache.felix.scr.annotations.Service;
import org.apache.sling.api.resource.LoginException;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceResolverFactory;
import org.apache.sling.api.resource.ValueMap;
import org.apache.sling.api.resource.observation.ExternalResourceChangeListener;
import org.apache.sling.api.resource.observation.ResourceChange;
import org.apache.sling.api.resource.observation.ResourceChangeListener;
import org.apache.sling.commons.osgi.PropertiesUtil;
import org.apache.sling.commons.scheduler.ScheduleOptions;
import org.apache.sling.commons.scheduler.Scheduler;
import org.apache.sling.i18n.ResourceBundleProvider;
import org.osgi.framework.BundleContext;
import org.osgi.framework.ServiceRegistration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The <code>JcrResourceBundleProvider</code> implements the
* <code>ResourceBundleProvider</code> interface creating
* <code>ResourceBundle</code> instances from resources stored in the
* repository.
*/
@Component(immediate = true, metatype = true, label = "%provider.name", description = "%provider.description")
@Service({ResourceBundleProvider.class, ResourceChangeListener.class})
@Property(name=ResourceChangeListener.PATHS, value="/")
public class JcrResourceBundleProvider implements ResourceBundleProvider, ResourceChangeListener, ExternalResourceChangeListener {
private static final boolean DEFAULT_PRELOAD_BUNDLES = false;
private static final int DEFAULT_INVALIDATION_DELAY = 5000;
private static final String SLING_I18N_USER = "sling-i18n";
@Property(value = "en")
private static final String PROP_DEFAULT_LOCALE = "locale.default";
@Property(boolValue = DEFAULT_PRELOAD_BUNDLES)
private static final String PROP_PRELOAD_BUNDLES = "preload.bundles";
@Property(longValue = DEFAULT_INVALIDATION_DELAY)
private static final String PROP_INVALIDATION_DELAY = "invalidation.delay";
@Reference
private Scheduler scheduler;
/** job names of scheduled jobs for reloading individual bundles */
private final Collection<String> scheduledJobNames = Collections.synchronizedList(new ArrayList<String>()) ;
/** default log */
private final Logger log = LoggerFactory.getLogger(getClass());
@Reference
private ResourceResolverFactory resourceResolverFactory;
/**
* The default Locale as configured with the <i>locale.default</i>
* configuration property. This defaults to <code>Locale.ENGLISH</code> if
* the configuration property is not set.
*/
private Locale defaultLocale = Locale.ENGLISH;
/**
* The resource resolver used to access the resource bundles. This object is
* retrieved from the {@link #resourceResolverFactory} using the {@link #SLING_I18N_USER} service user session.
*/
private ResourceResolver resourceResolver;
/**
* Map of cached resource bundles indexed by a key combined of the base name
* and <code>Locale</code> used to load and identify the <code>ResourceBundle</code>.
*/
private final ConcurrentHashMap<Key, JcrResourceBundle> resourceBundleCache = new ConcurrentHashMap<Key, JcrResourceBundle>();
private final ConcurrentHashMap<Key, Semaphore> loadingGuards = new ConcurrentHashMap<Key, Semaphore>();
/**
* paths from which JCR resource bundles have been loaded
*/
private final Set<String> languageRootPaths = Collections.newSetFromMap(new ConcurrentHashMap<String, Boolean>());
/**
* Return root resource bundle as created on-demand by
* {@link #getRootResourceBundle()}.
*/
private ResourceBundle rootResourceBundle;
private BundleContext bundleContext;
/**
* Each ResourceBundle is registered as a service. Each registration is stored in this map with the locale & base name used as a key.
*/
private final Map<Key, ServiceRegistration<ResourceBundle>> bundleServiceRegistrations = new HashMap<Key, ServiceRegistration<ResourceBundle>>();
private boolean preloadBundles;
private long invalidationDelay;
// ---------- ResourceBundleProvider ---------------------------------------
/**
* Returns the configured default <code>Locale</code> which is used as a
* fallback for {@link #getResourceBundle(Locale)} and also as the basis for
* any messages requested from resource bundles.
*/
@Override
public Locale getDefaultLocale() {
return defaultLocale;
}
/**
* Returns the <code>ResourceBundle</code> for the given
* <code>locale</code>.
*
* @param locale The <code>Locale</code> for which to return the resource
* bundle. If this is <code>null</code> the configured
* {@link #getDefaultLocale() default locale} is assumed.
* @return The <code>ResourceBundle</code> for the given locale.
* @throws MissingResourceException If the <code>ResourceResolver</code>
* is not available to access the resources.
*/
@Override
public ResourceBundle getResourceBundle(Locale locale) {
return getResourceBundle(null, locale);
}
@Override
public ResourceBundle getResourceBundle(String baseName, Locale locale) {
if (locale == null) {
locale = defaultLocale;
}
return getResourceBundleInternal(baseName, locale);
}
// ---------- EventHandler ------------------------------------------------
@Override
public void onChange(List<ResourceChange> changes) {
boolean refreshed = false;
for(final ResourceChange change : changes) {
log.trace("handleEvent: Detecting event {} for path '{}'", change.getType(), change.getPath());
// if this change was on languageRootPath level this might change basename and locale as well, therefore
// invalidate everything
if (languageRootPaths.contains(change.getPath())) {
log.debug(
"handleEvent: Detected change of cached language root '{}', removing all cached ResourceBundles",
change.getPath());
scheduleReloadBundles(true);
} else {
// if it is only a change below a root path, only messages of one resource bundle can be affected!
for (final String root : languageRootPaths) {
if (change.getPath().startsWith(root)) {
// figure out which JcrResourceBundle from the cached ones is affected
for (JcrResourceBundle bundle : resourceBundleCache.values()) {
if (bundle.getLanguageRootPaths().contains(root)) {
// reload it
log.debug("handleEvent: Resource changes below '{}', reloading ResourceBundle '{}'",
root, bundle);
scheduleReloadBundle(bundle);
return;
}
}
log.debug("handleEvent: No cached resource bundle found with root '{}'", root);
break;
}
}
// may be a completely new dictionary
if (!refreshed) {
// refresh at most once per onChange()
resourceResolver.refresh();
refreshed = true;
}
if (isDictionaryResource(change)) {
scheduleReloadBundles(true);
}
}
}
}
private boolean isDictionaryResource(final ResourceChange change) {
// language node changes happen quite frequently (https://issues.apache.org/jira/browse/SLING-2881)
// therefore only consider changes either for sling:MessageEntry's
// or for JSON dictionaries
// get valuemap
final Resource resource = resourceResolver.getResource(change.getPath());
if (resource == null) {
log.trace("Could not get resource for '{}' for event {}", change.getPath(), change.getType());
return false;
}
if ( resource.getResourceType() == null ) {
return false;
}
if (resource.isResourceType(JcrResourceBundle.RT_MESSAGE_ENTRY)) {
log.debug("Found new dictionary entry: New {} resource in '{}' detected", JcrResourceBundle.RT_MESSAGE_ENTRY, change.getPath());
return true;
}
final ValueMap valueMap = resource.getValueMap();
// FIXME: derivatives from mix:Message are not detected
if (hasMixin(valueMap, JcrResourceBundle.MIXIN_MESSAGE)) {
log.debug("Found new dictionary entry: New {} resource in '{}' detected", JcrResourceBundle.MIXIN_MESSAGE, change.getPath());
return true;
}
if (change.getPath().endsWith(".json")) {
// check for mixin
if (hasMixin(valueMap, JcrResourceBundle.MIXIN_LANGUAGE)) {
log.debug("Found new dictionary: New {} resource in '{}' detected", JcrResourceBundle.MIXIN_LANGUAGE, change.getPath());
return true;
}
}
return false;
}
private boolean hasMixin(ValueMap valueMap, String mixin) {
final String[] mixins = valueMap.get(JcrResourceBundle.PROP_MIXINS, String[].class);
if ( mixins != null ) {
for(final String m : mixins) {
if (mixin.equals(m) ) {
return true;
}
}
}
return false;
}
private void scheduleReloadBundles(boolean withDelay) {
// cancel all reload individual bundle jobs!
synchronized(scheduledJobNames) {
for (String scheduledJobName : scheduledJobNames) {
scheduler.unschedule(scheduledJobName);
}
}
scheduledJobNames.clear();
// defer this job
final ScheduleOptions options;
if (withDelay) {
options = scheduler.AT(new Date(System.currentTimeMillis() + invalidationDelay));
} else {
options = scheduler.NOW();
}
options.name("JcrResourceBundleProvider: reload all resource bundles");
scheduler.schedule(new Runnable() {
@Override
public void run() {
log.info("Reloading all resource bundles");
clearCache();
preloadBundles();
}
}, options);
}
private void scheduleReloadBundle(JcrResourceBundle bundle) {
String baseName = bundle.getBaseName();
Locale locale = bundle.getLocale();
final Key key = new Key(baseName, locale);
// defer this job
ScheduleOptions options = scheduler.AT(new Date(System.currentTimeMillis() + invalidationDelay));
final String jobName = "JcrResourceBundleProvider: reload bundle with key " + key.toString();
scheduledJobNames.add(jobName);
options.name(jobName);
scheduler.schedule(new Runnable() {
@Override
public void run() {
reloadBundle(key);
scheduledJobNames.remove(jobName);
}
}, options);
}
void reloadBundle(final Key key) {
// remove bundle from cache
resourceBundleCache.remove(key);
log.info("Reloading resource bundle for {}", key);
// unregister bundle
ServiceRegistration<ResourceBundle> serviceRegistration = null;
synchronized (this) {
serviceRegistration = bundleServiceRegistrations.remove(key);
}
if (serviceRegistration != null) {
serviceRegistration.unregister();
} else {
log.warn("Could not find resource bundle service for {}", key);
}
Collection<JcrResourceBundle> dependentBundles = new ArrayList<JcrResourceBundle>();
// this bundle might be a parent of a cached bundle -> invalidate those dependent bundles as well
for (JcrResourceBundle bundle : resourceBundleCache.values()) {
if (bundle.getParent() instanceof JcrResourceBundle) {
JcrResourceBundle parentBundle = (JcrResourceBundle) bundle.getParent();
Key parentKey = new Key(parentBundle.getBaseName(), parentBundle.getLocale());
if (parentKey.equals(key)) {
log.debug("Also invalidate dependent bundle {} which has bundle {} as parent", bundle, parentBundle);
dependentBundles.add(bundle);
}
}
}
for (JcrResourceBundle dependentBundle : dependentBundles) {
reloadBundle(new Key(dependentBundle.getBaseName(), dependentBundle.getLocale()));
}
if (preloadBundles) {
// reload the bundle from the repository (will also fill cache and register as a service)
getResourceBundle(key.baseName, key.locale);
}
}
// ---------- SCR Integration ----------------------------------------------
/**
* Activates and configures this component with the repository access
* details and the default locale to use
* @throws LoginException
*/
protected void activate(BundleContext context, Map<String, Object> props) throws LoginException {
String localeString = PropertiesUtil.toString(props.get(PROP_DEFAULT_LOCALE),
null);
this.defaultLocale = toLocale(localeString);
this.preloadBundles = PropertiesUtil.toBoolean(props.get(PROP_PRELOAD_BUNDLES), DEFAULT_PRELOAD_BUNDLES);
this.bundleContext = context;
invalidationDelay = PropertiesUtil.toLong(props.get(PROP_INVALIDATION_DELAY), DEFAULT_INVALIDATION_DELAY);
if (this.resourceResolverFactory != null) { // this is only null during test execution!
Map<String, Object> authInfo = Collections.<String, Object>singletonMap(
ResourceResolverFactory.SUBSERVICE, SLING_I18N_USER);
resourceResolver = resourceResolverFactory.getServiceResourceResolver(authInfo);
scheduleReloadBundles(false);
}
}
protected void deactivate() {
clearCache();
resourceResolver.close();
}
// ---------- internal -----------------------------------------------------
/**
* Internal implementation of the {@link #getResourceBundle(Locale)} method
* employing the cache of resource bundles. Creates the bundle if not
* already cached.
*
* @throws MissingResourceException If the resource bundles needs to be
* created and the <code>ResourceResolver</code> is not
* available to access the resources.
*/
private ResourceBundle getResourceBundleInternal(final String baseName, final Locale locale) {
final Key key = new Key(baseName, locale);
JcrResourceBundle resourceBundle = resourceBundleCache.get(key);
if (resourceBundle != null) {
log.debug("getResourceBundleInternal({}): got cache hit on first try", key);
} else {
if (loadingGuards.get(key) == null) {
loadingGuards.putIfAbsent(key, new Semaphore(1));
}
final Semaphore loadingGuard = loadingGuards.get(key);
try {
loadingGuard.acquire();
resourceBundle = resourceBundleCache.get(key);
if (resourceBundle != null) {
log.debug("getResourceBundleInternal({}): got cache hit on second try", key);
} else {
log.debug("getResourceBundleInternal({}): reading from Repository", key);
resourceBundle = createResourceBundle(key.baseName, key.locale);
resourceBundleCache.put(key, resourceBundle);
registerResourceBundle(key, resourceBundle);
}
} catch (InterruptedException e) {
Thread.interrupted();
} finally {
loadingGuard.release();
}
}
log.trace("getResourceBundleInternal({}) ==> {}", key, resourceBundle);
return resourceBundle;
}
private void registerResourceBundle(Key key, JcrResourceBundle resourceBundle) {
Dictionary<String, Object> serviceProps = new Hashtable<String, Object>();
if (key.baseName != null) {
serviceProps.put("baseName", key.baseName);
}
serviceProps.put("locale", key.locale.toString());
ServiceRegistration<ResourceBundle> serviceReg = bundleContext.registerService(ResourceBundle.class,
resourceBundle, serviceProps);
synchronized (this) {
bundleServiceRegistrations.put(key, serviceReg);
}
// register language root paths
final Set<String> languageRoots = resourceBundle.getLanguageRootPaths();
this.languageRootPaths.addAll(languageRoots);
log.debug("registerResourceBundle({}, ...): added service registration and language roots {}", key, languageRoots);
log.info("Currently loaded dictionaries across all locales: {}", languageRootPaths);
}
/**
* Creates the resource bundle for the give locale.
*
* @throws MissingResourceException If the <code>ResourceResolver</code>
* is not available to access the resources.
*/
private JcrResourceBundle createResourceBundle(String baseName, Locale locale) {
final JcrResourceBundle bundle = new JcrResourceBundle(locale, baseName, resourceResolver);
// set parent resource bundle
Locale parentLocale = getParentLocale(locale);
if (parentLocale != null) {
bundle.setParent(getResourceBundleInternal(baseName, parentLocale));
} else {
bundle.setParent(getRootResourceBundle());
}
return bundle;
}
/**
* Returns the parent locale of the given locale. The parent locale is the
* locale of a locale is defined as follows:
* <ol>
* <li>If the locale has an variant, the parent locale is the locale with
* the same language and country without the variant.</li>
* <li>If the locale has no variant but a country, the parent locale is the
* locale with the same language but neither country nor variant.</li>
* <li>If the locale has no country and not variant and whose language is
* different from the language of the the configured default locale, the
* parent locale is the configured default locale.</li>
* <li>Otherwise there is no parent locale and <code>null</code> is
* returned.</li>
* </ol>
*/
private Locale getParentLocale(Locale locale) {
if (locale.getVariant().length() != 0) {
return new Locale(locale.getLanguage(), locale.getCountry());
} else if (locale.getCountry().length() != 0) {
return new Locale(locale.getLanguage());
} else if (!locale.getLanguage().equals(defaultLocale.getLanguage())) {
return defaultLocale;
}
// no more parents
return null;
}
/**
* Returns a ResourceBundle which is used as the root resource bundle, that
* is the ultimate parent:
* <ul>
* <li><code>getLocale()</code> returns Locale("", "", "")</li>
* <li><code>handleGetObject(String key)</code> returns the <code>key</code></li>
* <li><code>getKeys()</code> returns an empty enumeration.
* </ul>
*
* @return The root resource bundle
*/
private ResourceBundle getRootResourceBundle() {
if (rootResourceBundle == null) {
rootResourceBundle = new RootResourceBundle();
}
return rootResourceBundle;
}
private void clearCache() {
resourceBundleCache.clear();
languageRootPaths.clear();
synchronized (this) {
for (ServiceRegistration<ResourceBundle> serviceReg : bundleServiceRegistrations.values()) {
serviceReg.unregister();
}
bundleServiceRegistrations.clear();
}
}
private void preloadBundles() {
if (preloadBundles) {
resourceResolver.refresh();
Iterator<Map<String, Object>> bundles = resourceResolver.queryResources(
JcrResourceBundle.QUERY_LANGUAGE_ROOTS, "xpath");
Set<Key> usedKeys = new HashSet<Key>();
while (bundles.hasNext()) {
Map<String,Object> bundle = bundles.next();
if (bundle.containsKey(PROP_LANGUAGE)) {
Locale locale = toLocale(bundle.get(PROP_LANGUAGE).toString());
String baseName = null;
if (bundle.containsKey(PROP_BASENAME)) {
baseName = bundle.get(PROP_BASENAME).toString();
}
Key key = new Key(baseName, locale);
if (usedKeys.add(key)) {
getResourceBundle(baseName, locale);
}
}
}
}
}
/**
* Converts the given <code>localeString</code> to a valid
* <code>java.util.Locale</code>. It must either be in the format specified by
* {@link Locale#toString()} or in <a href="https://tools.ietf.org/html/bcp47">BCP 47 format</a>
* If the locale string is <code>null</code> or empty, the platform default locale is assumed. If
* the localeString matches any locale available per default on the
* platform, that platform locale is returned. Otherwise the localeString is
* parsed and the language and country parts are compared against the
* languages and countries provided by the platform. Any unsupported
* language or country is replaced by the platform default language and
* country.
* @param localeString the locale as string
* @return the {@link Locale} being generated from the {@code localeString}
*/
static Locale toLocale(String localeString) {
if (localeString == null || localeString.length() == 0) {
return Locale.getDefault();
}
// support BCP 47 compliant strings as well (using a different separator "-" instead of "_")
localeString = localeString.replaceAll("-", "_");
// check language and country
final String[] parts = localeString.split("_");
if (parts.length == 0) {
return Locale.getDefault();
}
// at least language is available
String lang = parts[0];
boolean isValidLanguageCode = false;
String[] langs = Locale.getISOLanguages();
for (int i = 0; i < langs.length; i++) {
if (langs[i].equalsIgnoreCase(lang)) {
isValidLanguageCode = true;
break;
}
}
if (!isValidLanguageCode) {
lang = Locale.getDefault().getLanguage();
}
// only language
if (parts.length == 1) {
return new Locale(lang);
}
// country is also available
String country = parts[1];
boolean isValidCountryCode = false;
String[] countries = Locale.getISOCountries();
for (int i = 0; i < countries.length; i++) {
if (countries[i].equalsIgnoreCase(country)) {
isValidCountryCode = true; // signal ok
break;
}
}
if (!isValidCountryCode) {
country = Locale.getDefault().getCountry();
}
// language and country
if (parts.length == 2) {
return new Locale(lang, country);
}
// language, country and variant
return new Locale(lang, country, parts[2]);
}
//---------- internal class
/**
* The <code>Key</code> class encapsulates the base name and Locale in a
* single object that can be used as the key in a <code>HashMap</code>.
*/
protected static final class Key {
final String baseName;
final Locale locale;
// precomputed hash code, because this will always be used due to
// this instance being used as a key in a HashMap.
private final int hashCode;
Key(final String baseName, final Locale locale) {
int hc = 0;
if (baseName != null) {
hc += 17 * baseName.hashCode();
}
if (locale != null) {
hc += 13 * locale.hashCode();
}
this.baseName = baseName;
this.locale = locale;
this.hashCode = hc;
}
@Override
public int hashCode() {
return hashCode;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
} else if (obj instanceof Key) {
Key other = (Key) obj;
return equals(this.baseName, other.baseName)
&& equals(this.locale, other.locale);
}
return false;
}
private static boolean equals(Object o1, Object o2) {
if (o1 == null) {
if (o2 != null) {
return false;
}
} else if (!o1.equals(o2)) {
return false;
}
return true;
}
@Override
public String toString() {
return "Key(" + baseName + ", " + locale + ")";
}
}
}