org.olat.core.util.i18n.I18nManager.java Source code

Java tutorial

Introduction

Here is the source code for org.olat.core.util.i18n.I18nManager.java

Source

/**
* OLAT - Online Learning and Training<br>
* http://www.olat.org
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); <br>
* you may not use this file except in compliance with the License.<br>
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing,<br>
* software distributed under the License is distributed on an "AS IS" BASIS, <br>
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
* See the License for the specific language governing permissions and <br>
* limitations under the License.
* <p>
* Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br>
* University of Zurich, Switzerland.
* <hr>
* <a href="http://www.openolat.org">
* OpenOLAT - Online Learning and Training</a><br>
* This file has been modified by the OpenOLAT community. Changes are licensed
* under the Apache 2.0 license as the original file.  
* <p>
*/

package org.olat.core.util.i18n;

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.text.DecimalFormat;
import java.text.MessageFormat;
import java.text.NumberFormat;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.Deque;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.jar.JarOutputStream;
import java.util.jar.Manifest;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.servlet.http.HttpServletRequest;

import org.apache.commons.io.IOUtils;
import org.json.JSONException;
import org.json.JSONObject;
import org.olat.core.CoreSpringFactory;
import org.olat.core.helpers.Settings;
import org.olat.core.logging.AssertException;
import org.olat.core.logging.OLATRuntimeException;
import org.olat.core.logging.OLog;
import org.olat.core.logging.StartupException;
import org.olat.core.logging.Tracing;
import org.olat.core.manager.BasicManager;
import org.olat.core.util.AlwaysEmptyMap;
import org.olat.core.util.CodeHelper;
import org.olat.core.util.FileUtils;
import org.olat.core.util.Formatter;
import org.olat.core.util.SortedProperties;
import org.olat.core.util.StringHelper;
import org.olat.core.util.UserSession;
import org.olat.core.util.WebappHelper;
import org.olat.core.util.session.UserSessionManager;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;

/**
 * Description: <br>
 * I18nManager is responsible for internationalization issues, to store and load properties files in the available languages. 
 * <P>
 * 
 * Initial Date: 10.11.2004 <br>
 * 
 * @author Felix Jost
 */

public class I18nManager extends BasicManager {
    private static final String BUNDLE_INLINE_TRANSLATION_INTERCEPTOR = "org.olat.core.util.i18n.ui";
    private static final String BUNDLE_EXCEPTION = "org.olat.core.gui.exception";
    private static I18nManager INSTANCE;
    private static final OLog log = Tracing.createLoggerFor(I18nManager.class);
    public static final String FILE_NOT_FOUND_ERROR_PREFIX = ":::file not found";
    public static final String USESS_KEY_I18N_MARK_LOCALIZED_STRINGS = "I18N_MARK_LOCALIZED_STRINGS";

    public static final String IDENT_END_POSTFIX = "_end@";
    public static final String IDENT_START_POSTFIX = "_start@";
    public static final String IDENT_PREFIX = "@itt_";
    private static final String METADATA_KEY = "METADATA";
    private static final String METADATA_FILENAME = "i18nBundleMetadata.properties";
    public static final String METADATA_ANNOTATION_POSTFIX = ".annotation";
    public static final String METADATA_BUNDLE_PRIORITY_KEY = "bundle.priority";
    public static final String METADATA_KEY_PRIORITY_POSTFIX = ".priority";
    public static final String METADATA_KEY_INLINEREANSLATION_POSTFIX = ".inlinetranslation";
    public static final String METADATA_KEY_INLINEREANSLATION_VALUE_DISABLED = "disabled";
    public static final String I18N_DIRNAME = "_i18n";

    // pattern to find recursive keys in values: $org.olat.package:my.key
    private static final Pattern resolvingKeyPattern = Pattern
            .compile("\\$\\{?([\\w\\.\\-]*):([\\w\\.\\-]*[\\w\\-])\\}?");
    public static final int DEFAULT_BUNDLE_PRIORITY = 500;
    public static final int DEFAULT_KEY_PRIORITY = 500;

    /**
     * Per-thread singleton holding the currently used Locale for translated
     * messages and a flag if the translated strings should be marked with some
     * markup.
     */
    private static ThreadLocalLocale threadLocalLocale = new ThreadLocalLocale();
    private static ThreadLocalMarkLocalizedStrings threadLocalIsMarkLocalizedStringsEnabled = new ThreadLocalMarkLocalizedStrings();

    // value: name of
    // helpcourse

    // keys: bundlename ":" locale.toString() (e.g. "org.olat.admin:de_DE");
    // values: PropertyFile
    private ConcurrentMap<String, String> cachedLangTranslated = new ConcurrentHashMap<String, String>();
    private ConcurrentMap<String, Properties> cachedBundles = new ConcurrentHashMap<String, Properties>();
    private ConcurrentMap<String, String> cachedJSTranslatorData = new ConcurrentHashMap<String, String>();
    private ConcurrentMap<String, Deque<String>> referencingBundlesIndex = new ConcurrentHashMap<String, Deque<String>>();
    private static final ConcurrentMap<Locale, String> localeToLocaleKey = new ConcurrentHashMap<>();
    private boolean cachingEnabled = true;

    private static FilenameFilter i18nFileFilter = new FilenameFilter() {
        public boolean accept(File dir, String name) {
            // don't add overlayLocales as selectable availableLanguages
            // (LocaleStrings_de__VENDOR.properties)
            if (name.startsWith(I18nModule.LOCAL_STRINGS_FILE_PREFIX) && name.indexOf("_") != 0
                    && name.endsWith(I18nModule.LOCAL_STRINGS_FILE_POSTFIX)) {
                return true;
            }
            return false;
        }
    };

    /**
     * @return the manager
     */
    public static I18nManager getInstance() {
        return INSTANCE;
    }

    /**
     * Get the translated value for a given i18n item. The returned translation is
     * the pure translated value: no fallback mechanism, no overlay mechanism, no
     * recursive resolving
     * 
     * @param i18nItem
     * @param args the arguments used in this item or NULL if no such arguments
     *          needed
     * @return The translated string.
     */
    public String getLocalizedString(I18nItem i18nItem, Object[] args) {
        return getLocalizedString(i18nItem.getBundleName(), i18nItem.getKey(), args, i18nItem.getLocale(), false,
                false, false, false, 0);
    }

    /**
     * Get a localized string. The translation order is as follows:
     * <ol>
     * <li>a) Look in overlay of given locale if overlay is configured</li>
     * <li>b) Look in given locale</li>
     * <li>c) Fallback to overlay of locale without variant if given locale has a
     * variant</li>
     * <li>d) Fallback to locale without variant if given locale has a variant</li>
     * <li>e) Fallback to overlay of locale without country if given locale has a
     * country</li>
     * <li>f) Fallback to locale without country if given locale has a country</li>
     * <li>g) Fallback to overlay of default locale</li>
     * <li>h) Fallback to default locale</li>
     * <li>i) Fallback to overlay of reference and fallback locale</li>
     * <li>j) Fallback to reference and fallback locale</li>
     * <li>k) Print error message</li>
     * </ol>
     * 
     * @param bundlePath The bundle path
     * @param key The key too lookup
     * @param args The arguments used while formatting or NULL if no arguments
     * @param locale The locale to use
     * @param overlayEnabled true: lookup first in overlay; false: don't lookup in overlay
     * @param fallBackToDefaultLocale true: fallback to default enabled; false: no
     *          fallback
     * @return The formatted message in the given language or NULL if no fallback
     *         possible and not found
     */
    public String getLocalizedString(String bundleName, String key, Object[] args, Locale locale,
            boolean overlayEnabled, boolean fallBackToDefaultLocale) {
        return getLocalizedString(bundleName, key, args, locale, overlayEnabled, fallBackToDefaultLocale, true,
                true, true, 0);
    }

    public String getLocalizedString(String bundleName, String key, Object[] args, Locale locale,
            boolean overlayEnabled, boolean fallBackToDefaultLocale, boolean fallBackToFallbackLocale,
            boolean resolveRecursively, int recursionLevel) {
        return getLocalizedString(bundleName, key, args, locale, overlayEnabled, fallBackToDefaultLocale,
                fallBackToFallbackLocale, resolveRecursively, true, recursionLevel);
    }

    private final String getLocalizedString(String bundleName, String key, Object[] args, Locale locale,
            boolean overlayEnabled, boolean fallBackToDefaultLocale, boolean fallBackToFallbackLocale,
            boolean resolveRecursively, boolean allowDecoration, int recursionLevel) {
        String msg = null;
        Properties properties = null;
        // a) If the overlay is enabled, lookup first in the overlay property
        // file
        if (overlayEnabled) {

            Locale overlayLocale = I18nModule.getOverlayLocales().get(locale);
            if (overlayLocale != null) {
                properties = getProperties(overlayLocale, bundleName, resolveRecursively, recursionLevel);
                if (properties != null) {
                    msg = properties.getProperty(key);
                    //               if (log.isDebug() && msg == null) {
                    //                  log.debug("Key::" + key + " not found in overlay::" + I18nModule.getOverlayName() + " for bundle::" + bundleName
                    //                        + " and locale::" + locale.toString(), null);
                    //               }
                }
            }
        }
        // b) Otherwhise lookup in the regular bundle
        if (msg == null) {
            properties = getProperties(locale, bundleName, resolveRecursively, recursionLevel);
            // if LocalStrings File does not exist -> return error msg on screen
            // / fallback to default language
            if (properties == null) {
                if (Settings.isDebuging()) {
                    log.warn(FILE_NOT_FOUND_ERROR_PREFIX + "! locale::" + locale.toString() + ", path::"
                            + bundleName, null);
                }
            } else {
                msg = properties.getProperty(key);
            }
        }

        // The following fallback behaviour is similar to
        // java.util.ResourceBundle
        if (msg == null) {
            if (log.isDebug()) {
                log.debug("Key::" + key + " not found for bundle::" + bundleName + " and locale::"
                        + locale.toString(), null);
            }
            // Fallback on the language if the locale has a country and/or a
            // variant
            // de_DE_variant -> de_DE -> de
            // Only after having all those checked we will fallback to the
            // default language
            // 1. Check on variant
            String variant = locale.getVariant();
            if (!variant.equals("")) {
                Locale newLoc = I18nModule.getAllLocales().get(locale.getLanguage() + "_" + locale.getCountry());
                if (newLoc != null)
                    msg = getLocalizedString(bundleName, key, args, newLoc, overlayEnabled, false,
                            fallBackToFallbackLocale, resolveRecursively, recursionLevel);
            }
            if (msg == null) {
                // 2. Check on country
                String country = locale.getCountry();
                if (!country.equals("")) {
                    Locale newLoc = I18nModule.getAllLocales().get(locale.getLanguage());
                    if (newLoc != null)
                        msg = getLocalizedString(bundleName, key, args, newLoc, overlayEnabled, false,
                                fallBackToFallbackLocale, resolveRecursively, recursionLevel);
                }
                // else we have an original locale with only a language given ->
                // no language specific fallbacks anymore
            }
        }

        if (msg == null) {
            // Message still empty? Use fallback to default language?
            // yes: return the call applied with the olatcore default locale
            // no: return null to indicate nothing was found so that callers may
            // use fallbacks
            if (fallBackToDefaultLocale) {
                return getLocalizedString(bundleName, key, args, I18nModule.getDefaultLocale(), overlayEnabled,
                        false, fallBackToFallbackLocale, resolveRecursively, recursionLevel);
            } else {
                if (fallBackToFallbackLocale) {
                    // fallback to fallback locale
                    Locale fallbackLocale = I18nModule.getFallbackLocale();
                    if (fallbackLocale.equals(locale)) {
                        // finish when when already in fallback locale
                        if (isLogDebugEnabled()) {
                            logWarn("Could not find translation for bundle::" + bundleName + " and key::" + key
                                    + " ; not even in default or fallback packages", null);
                        }
                        return null;
                    } else {
                        return getLocalizedString(bundleName, key, args, fallbackLocale, overlayEnabled, false,
                                false, resolveRecursively, recursionLevel);
                    }
                } else {
                    return null;
                }
            }
        }
        // When caching not enabled we need to check for keys contained in this
        // value. In caching mode this is already done while loading the
        // properties
        // file
        if (resolveRecursively && (!cachingEnabled || properties != null)) {
            msg = resolveValuesInternalKeys(locale, bundleName, key, properties, overlayEnabled, recursionLevel,
                    msg);
        }

        // Add markup code to identify translated strings
        if (allowDecoration && isCurrentThreadMarkLocalizedStringsEnabled()
                && !bundleName.startsWith(BUNDLE_INLINE_TRANSLATION_INTERCEPTOR)
                && !bundleName.startsWith(BUNDLE_EXCEPTION) && isInlineTranslationEnabledForKey(bundleName, key)) {
            // identifyer consists of bundle name and key and an id to
            // distinguish multiple translations of the same key
            String identifyer = bundleName + ":" + key + ":" + CodeHelper.getRAMUniqueID();
            msg = IDENT_PREFIX + identifyer + IDENT_START_POSTFIX + msg + IDENT_PREFIX + identifyer
                    + IDENT_END_POSTFIX;
        }
        // Add the the {0},{1} arguments to the GUI message
        if (args == null) {
            return msg;
        } else {
            // Escape single quotes with single quotes. Single quotes have special meaning in MessageFormat
            // See OLAT-5107, OLAT-5756
            if (msg.indexOf("'") > -1) {
                msg = msg.replaceAll("'", "''");
            }
            return MessageFormat.format(msg, args);
        }
    }

    /**
     * Get the annotation for this i18n item. There exists only one annotation per
     * key, it is shared between all languages
     * 
     * @param i18nItem
     * @return the annotation or NULL if no annotation exists
     */
    public String getAnnotation(I18nItem i18nItem) {
        Properties properties = getPropertiesWithoutResolvingRecursively(null, i18nItem.getBundleName());
        String key = i18nItem.getKey() + METADATA_ANNOTATION_POSTFIX;
        return properties.getProperty(key);
    }

    public void setAnnotation(I18nItem i18nItem, String annotation) {
        Properties properties = getPropertiesWithoutResolvingRecursively(null, i18nItem.getBundleName());
        String key = i18nItem.getKey() + METADATA_ANNOTATION_POSTFIX;
        if (StringHelper.containsNonWhitespace(annotation)) {
            properties.setProperty(key, annotation);
        } else if (properties.containsKey(key)) {
            properties.remove(key);
        }
        if (properties.size() == 0) {
            // delete empty files
            deleteProperties(null, i18nItem.getBundleName());
        } else {
            // update
            saveOrUpdateProperties(properties, null, i18nItem.getBundleName());
        }
    }

    /**
     * Find all i18n items that exist in the target locale
     * 
     * @param targetLocale The locale that must be translated
     * @param limitToBundleName
     * @param includeBundlesChildren true: also find the keys in the bundles
     *          children; false: find only the keys in the exact bundle name. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return List of i18n items
     */
    public List<I18nItem> findExistingI18nItems(Locale targetLocale, String limitToBundleName,
            boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        List<I18nItem> foundTranslationItems = new LinkedList<I18nItem>();
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                Properties targetProperties = getResolvedProperties(targetLocale, bundleName);
                int bundlePriority = getBundlePriority(bundleName);
                Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
                Set<Object> keys = targetProperties.keySet(); // properties.stringPropertyNames()
                // is Java 1.6
                // only!
                for (Object keyObj : keys) {
                    String key = (String) keyObj;
                    int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                    I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority, keyPriority);
                    foundTranslationItems.add(i18nItem);
                }
            }
        }
        return foundTranslationItems;
    }

    /**
     * Find all i18n items that exist in the source locale but not in the target
     * locale
     * 
     * @param referenceLocale The locale that serves as the prototype
     * @param targetLocale The locale that must be translated
     * @param limitToBundleName
     * @param includeBundlesChildren true: also find the keys in the bundles
     *          children; false: find only the keys in the exact bundle name. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return List of i18n items
     */
    public List<I18nItem> findMissingI18nItems(Locale referenceLocale, Locale targetLocale,
            String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        List<I18nItem> foundTranslationItems = new LinkedList<I18nItem>();
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                Properties sourceProperties = getResolvedProperties(referenceLocale, bundleName);
                Properties targetProperties = getPropertiesWithoutResolvingRecursively(targetLocale, bundleName);
                Properties metadataProperties = getResolvedProperties(null, bundleName);
                int bundlePriority = getBundlePriority(bundleName);
                Set<Object> keys = sourceProperties.keySet(); // properties.stringPropertyNames()
                // is Java 1.6
                // only!
                for (Object keyObj : keys) {
                    String key = (String) keyObj;
                    if (!targetProperties.containsKey(key)) {
                        int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                        I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority,
                                keyPriority);
                        foundTranslationItems.add(i18nItem);
                    }
                }
            }
        }
        return foundTranslationItems;
    }

    /**
     * Find all i18n items that exist in the source locale and the target locale
     * 
     * @param referenceLocale The locale that serves as the prototype
     * @param targetLocale The locale that must be translated
     * @param limitToBundleName
     * @param includeBundlesChildren true: also find the keys in the bundles
     *          children; false: find only the keys in the exact bundle name. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return List of i18n items
     */
    public List<I18nItem> findExistingAndMissingI18nItems(Locale referenceLocale, Locale targetLocale,
            String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        List<I18nItem> foundTranslationItems = new LinkedList<I18nItem>();
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                // add from reference properties
                Properties referenceProperties = getResolvedProperties(referenceLocale, bundleName);
                Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
                int bundlePriority = getBundlePriority(bundleName);
                Set<Object> keys = referenceProperties.keySet(); // properties.stringPropertyNames()
                // is Java
                // 1.6 only!
                for (Object keyObj : keys) {
                    String key = (String) keyObj;
                    int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                    I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority, keyPriority);
                    foundTranslationItems.add(i18nItem);
                }
                // add from target properties
                Properties targetProperties = getResolvedProperties(targetLocale, bundleName);
                keys = targetProperties.keySet(); // properties.stringPropertyNames()
                // is Java 1.6 only!
                for (Object keyObj : keys) {
                    String key = (String) keyObj;
                    if (!referenceProperties.containsKey(key)) { // already
                        // added
                        int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                        I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority,
                                keyPriority);
                        foundTranslationItems.add(i18nItem);
                    }
                }
            }
        }
        return foundTranslationItems;
    }

    /**
     * Find all i18n items that contain a given search string in their value. The
     * search string can contain '*' as a wild-card
     * 
     * @param searchString The search string, case-insensitive. * are treated as
     *          wild-cards
     * @param searchLocale The locale where to search
     * @param targetLocale The locale that should be used as result target
     * @param limitToBundleName The name of a bundle in which the keys should be
     *          searched or NULL to search in all bundles
     * @param includeBundlesChildren true: also find the keys in the bundles
     *          children; false: find only the keys in the exact bundle name. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return List of i18n items
     */
    public List<I18nItem> findI18nItemsByValueSearch(String searchString, Locale searchLocale, Locale targetLocale,
            String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        List<I18nItem> foundTranslationItems = new LinkedList<I18nItem>();
        searchString = searchString.toLowerCase();
        String[] parts = searchString.split("\\*");
        // Build pattern
        String regexpSearchString = "^.*";
        for (String part : parts) {
            regexpSearchString += Pattern.quote(part) + ".*";
        }
        regexpSearchString += "$";
        Pattern p = Pattern.compile(regexpSearchString, Pattern.MULTILINE);
        // Search in all bundles and keys for that pattern
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                Properties properties = getResolvedProperties(searchLocale, bundleName);
                Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
                int bundlePriority = getBundlePriority(bundleName);
                for (Map.Entry<Object, Object> entry : properties.entrySet()) {
                    String value = (String) entry.getValue();
                    Matcher m = p.matcher(value.toLowerCase());
                    if (m.find()) {
                        String key = (String) entry.getKey();
                        int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                        I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority,
                                keyPriority);
                        foundTranslationItems.add(i18nItem);
                    }
                }
            }
        }
        return foundTranslationItems;
    }

    /**
     * Find all i18n items that contain the given search string in their key.
     * 
     * @param searchString The search string, case-insensitive
     * @param searchLocale The language to search in for
     * @param targetLocale The locale that should be used as result target
     * @param limitToBundleName The name of a bundle in which the keys should be
     *          searched or NULL to search in all bundles
     * @param includeBundlesChildren true: also find the keys in the bundles
     *          children; false: find only the keys in the exact bundle name. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return List of i18n items
     */
    public List<I18nItem> findI18nItemsByKeySearch(String searchString, Locale searchLocale, Locale targetLocale,
            String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        List<I18nItem> foundTranslationItems = new LinkedList<I18nItem>();
        searchString = searchString.toLowerCase();
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                Properties properties = getPropertiesWithoutResolvingRecursively(searchLocale, bundleName);
                Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
                int bundlePriority = getBundlePriority(bundleName);
                Set<Object> keys = properties.keySet(); // properties.stringPropertyNames()
                // is Java 1.6 only!
                for (Object keyObj : keys) {
                    String key = (String) keyObj;
                    if (key.toLowerCase().indexOf(searchString) != -1) {
                        int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
                        I18nItem i18nItem = new I18nItem(bundleName, key, targetLocale, bundlePriority,
                                keyPriority);
                        foundTranslationItems.add(i18nItem);
                    }
                }
            }
        }
        return foundTranslationItems;
    }

    /**
     * Factory method to create a single i18n item
     * 
     * @param bundleName
     * @param key
     * @param locale
     * @return
     */
    public I18nItem getI18nItem(String bundleName, String key, Locale locale) {
        int bundlePriority = getBundlePriority(bundleName);
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        int keyPriority = getKeyPriority(metadataProperties, key, bundleName);
        return new I18nItem(bundleName, key, locale, bundlePriority, keyPriority);
    }

    /**
     * Sort a list of i18n items. The list sorted alphabetically:
     * <ol>
     * <li>if afterBundlePriorities=true, the bundles are sorted by bundle
     * priority</li>
     * <li>if afterBundlePriorities=true and afterKeyPriorities=true, the bundles
     * are sorted by bundle and then by key priority</li>
     * <li>within the priorities, the bundles and keys are sorted alphabetically</li>
     * </ol>
     * 
     * @param i18nItems
     * @param afterBundlePriorities
     * @param afterKeyPriorities
     * @return
     */
    public void sortI18nItems(List<I18nItem> i18nItems, final boolean afterBundlePriorities,
            final boolean afterKeyPriorities) {
        Comparator<I18nItem> comparator = new Comparator<I18nItem>() {
            public int compare(I18nItem item1, I18nItem item2) {
                // 1) compare bundle
                if (afterBundlePriorities) {
                    int item1BundlePrio = item1.getBundlePriority();
                    int item2BundlePrio = item2.getBundlePriority();
                    if (item1BundlePrio < item2BundlePrio)
                        return -1;
                    if (item1BundlePrio > item2BundlePrio)
                        return 1;
                    // 2) in same bundle, compare key
                    if (afterKeyPriorities) {
                        int item1KeyPrio = item1.getKeyPriority();
                        int item2KeyPrio = item2.getKeyPriority();
                        if (item1KeyPrio < item2KeyPrio)
                            return -1;
                        if (item1KeyPrio > item2KeyPrio)
                            return 1;
                    }
                }
                // 3) same bundle or key prio or no prios used, compare
                // alphabetically
                // on bundle name
                int compareBundleNameResult = item1.getBundleName().compareTo(item2.getBundleName());
                if (compareBundleNameResult != 0) {
                    return compareBundleNameResult;
                } else {
                    // 4) in same bundle, compare alphabetically on key
                    return item1.getKey().compareTo(item2.getKey());
                }
            }
        };
        Collections.sort(i18nItems, comparator);
    }

    /**
     * Sort the bundle names alphabetically or by using the bundle priorities.
     * Take care when sorting a list you previously got from the Module - this
     * list is shared with all other users. Instead, use the sorted version
     * 
     * @param bundleNames
     * @param afterBundlePriorities
     */
    public void sortBundles(List<String> bundleNames, final boolean afterBundlePriorities) {
        Comparator<String> comparator = new Comparator<String>() {
            public int compare(String bundle1, String bundle2) {
                // 1) compare bundle priority
                if (afterBundlePriorities) {
                    int bundle1Prio = getBundlePriority(bundle1);
                    int bundle2Prio = getBundlePriority(bundle2);
                    if (bundle1Prio < bundle2Prio)
                        return -1;
                    if (bundle1Prio > bundle2Prio)
                        return 1;
                }
                // 2) compare alphabetically on bundle name
                return bundle1.compareTo(bundle2);
            }
        };
        Collections.sort(bundleNames, comparator);

    }

    /**
     * Save the given value for the given i18nItem
     * 
     * @param i18nItem
     * @param value
     */
    public void saveOrUpdateI18nItem(I18nItem i18nItem, String value) {
        Properties properties = getPropertiesWithoutResolvingRecursively(i18nItem.getLocale(),
                i18nItem.getBundleName());
        // Add logging block to find bogus save issues
        if (isLogDebugEnabled()) {
            String itemIdent = i18nItem.getLocale() + ":"
                    + buildI18nItemIdentifyer(i18nItem.getBundleName(), i18nItem.getKey());
            if (properties.containsKey(i18nItem.getKey())) {
                if (StringHelper.containsNonWhitespace(value)) {
                    logDebug("Updating i18n item::" + itemIdent + " with new value::" + value, null);
                } else {
                    logDebug("Deleting i18n item::" + itemIdent + " because new value is emty", null);
                }
            } else {
                if (StringHelper.containsNonWhitespace(value)) {
                    logDebug("Creating i18n item::" + itemIdent + " with new value::" + value, null);
                }
            }
        }
        //
        if (StringHelper.containsNonWhitespace(value)) {
            properties.setProperty(i18nItem.getKey(), value);
        } else if (properties.containsKey(i18nItem.getKey())) {
            properties.remove(i18nItem.getKey());
        }
        if (properties.size() == 0) {
            // delete empty files
            deleteProperties(i18nItem.getLocale(), i18nItem.getBundleName());
        } else {
            // update
            saveOrUpdateProperties(properties, i18nItem.getLocale(), i18nItem.getBundleName());
        }
        // remove all properties files from cache that contain references to
        // this i18n item, rebuild them lazy on next demand.
        if (cachingEnabled) {
            String identifyer = buildI18nItemIdentifyer(i18nItem.getBundleName(), i18nItem.getKey());
            Deque<String> referencingBundles = referencingBundlesIndex.get(identifyer);
            if (referencingBundles != null) {
                // remove from index
                referencingBundlesIndex.remove(identifyer);
                // remove from bundles cache
                for (String bundleName : referencingBundles) {
                    cachedBundles.remove(bundleName);
                }
            }
        }

    }

    /**
     * Count the i18n items in a bundle
     * 
     * @param locale
     * @param limitToBundleName The name of a bundle for which the keys should be
     *          counted or NULL to count keys in every available bundle
     * @param includeBundlesChildren true: also count the keys of the bundles
     *          children; false: count only the keys of the exact bundle name.
     *          When limitToBundeName is set to NULL the includeBundlesChildren
     *          will always be set to true
     * @return
     */
    public int countI18nItems(Locale locale, String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        int counter = 0;
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                Properties properties = getResolvedProperties(locale, bundleName);
                counter += properties.size();
            }
        }
        return counter;
    }

    /**
     * Count the number of available bundles
     * 
     * @param limitToBundleName The name of a bundle or NULL to count every
     *          available bundle
     * @param includeBundlesChildren true: also count the bundles children; false:
     *          count only the exact bundle name which will always be 1. When
     *          limitToBundeName is set to NULL the includeBundlesChildren will
     *          always be set to true
     * @return
     */
    public int countBundles(String limitToBundleName, boolean includeBundlesChildren) {
        List<String> allBundles = I18nModule.getBundleNamesContainingI18nFiles();
        if (limitToBundleName == null) {
            return allBundles.size();
        } else if (!includeBundlesChildren) {
            return (allBundles.contains(limitToBundleName) ? 1 : 0);
        }
        // else count bundle plus its children
        int counter = 0;
        for (String bundleName : allBundles) {
            if (limitToBundleName == null || limitToBundleName.equals(bundleName)
                    || (includeBundlesChildren && bundleName.startsWith(limitToBundleName))) {
                counter++;
            }
        }
        return counter;
    }

    /**
     * @param locale
     * @param bundleName
     * @return the properties for the given locale and bundlename. When no file is
     *         found, an emtpy properties object will be returned
     */
    public Properties getPropertiesWithoutResolvingRecursively(Locale locale, String bundleName) {
        return getProperties(locale, bundleName, false, 0);
    }

    public Properties getResolvedProperties(Locale locale, String bundleName) {
        return getProperties(locale, bundleName, true, 0);
    }

    private Properties getProperties(Locale locale, String bundleName, boolean resolveRecursively,
            int recursionLevel) {
        String key = calcPropertiesFileKey(locale, bundleName);
        Properties props = cachedBundles.get(key);
        boolean logDebug = false; // hide messaged for the moment until we find the other issue
        // Try cache first, load if needed
        // o_clusterOK by:fj i18n files are static, read-only in production
        // Not loaded yet or use the unresolved version which is always read
        // from disk. When caching is disabled, the cache will always return
        // null (AlwaysEmptyMap).
        if (props == null || !resolveRecursively) {
            props = readPropertiesFile(locale, bundleName, key, logDebug);

            // Try to resolve all keys within this properties and add to
            // cache
            if (resolveRecursively) {
                resolvePropertiesInternalKeys(locale, bundleName, props, I18nModule.isOverlayEnabled(),
                        recursionLevel);
                cachedBundles.put(key, props);
            }
            if (locale == null) {
                // Add metadata files to cache as well
                cachedBundles.put(key, props);
            }
        }
        return props;
    }

    private Properties readPropertiesFile(Locale locale, String bundleName, String key, boolean logDebug) {
        InputStream is = null;
        Properties props = new SortedProperties();
        try {
            // Start with an empty property object
            // Use a sorted properties object that saves the keys sorted alphabetically to disk
            //
            // 1) Try to load the bundle from a configured source path
            // This is also used to load the overlay properties
            File baseDir = I18nModule.getPropertyFilesBaseDir(locale, bundleName);
            if (baseDir != null) {
                File f = getPropertiesFile(locale, bundleName, baseDir);
                // if file exists load properties from file, otherwise
                // proceed with 2)
                if (f.exists()) {
                    is = new FileInputStream(f);
                    if (logDebug)
                        logDebug("loading LocalStrings from file::" + f.getAbsolutePath(), null);
                }
            }
            //
            // 2) Try to load from classpath
            // No "/" at the beginning of the resource! since the
            // resource will not be found within jars
            if (is == null) {
                String fileName = (locale == null ? METADATA_FILENAME : buildI18nFilename(locale));
                String relPath = bundleName.replace('.', '/') + "/" + I18N_DIRNAME + "/" + fileName;
                ClassLoader classLoader = this.getClass().getClassLoader();
                is = classLoader.getResourceAsStream(relPath);
                if (logDebug && is != null)
                    logDebug("loading LocalStrings from classpath relpath::" + relPath, null);
            }
            // Now load the properties from resource (file, classpath or
            // langpacks)
            if (is != null) {
                props.load(is);
            }
        } catch (IOException e) {
            throw new AssertException("LocalStrings for key::" + key + " could not be loaded", e);
        } finally {
            IOUtils.closeQuietly(is);
        }
        return props;
    }

    /**
     * Internal helper to resolve a key recursively within the property values.
     * All values of the given properties are evaluated and replaced by the
     * resolved values.
     * <p>
     * The recursion is limited to 10 levels to prevent endless loops
     * 
     * @param locale
     * @param bundleName
     * @param properties
     * @param overlayEnabled true: lookup first in overlay; false: don't lookup in overlay
     * @param recursionLevel the current recursion level. Incremented within this
     *          method
     * @return true: the properties contains at least one resolved property;
     *         false: properties not modified
     */
    private boolean resolvePropertiesInternalKeys(Locale locale, String bundleName, Properties properties,
            boolean overlayEnabled, int recursionLevel) {
        if (!cachingEnabled) {
            // Fails if caching is not enabled. In non-caching mode, the strings
            // are converted on the fly
            return false;
        }

        Set<Object> keys = properties.keySet();
        for (Object keyObj : keys) {
            String key = (String) keyObj;
            String value = properties.getProperty(key);
            String resolvedValue = resolveValuesInternalKeys(locale, bundleName, key, properties, overlayEnabled,
                    recursionLevel, value);
            // Set new value
            properties.setProperty(key, resolvedValue);
        }
        return true;
    }

    /**
     * Internal helper to resolve keys within the given value. The optional
     * currentProperties can be used to improve performance when looking up
     * something in the current properties file
     * 
     * @param locale
     * @param bundleName
     * @param key
     * @param currentProperties
     * @param overlayEnabled true: lookup first in overlay; false: don't lookup in overlay
     * @param recursionLevel
     * @param recursionLevel the current recursion level. Incremented within this
     *          method
     * @return the resolved value
     */
    private String resolveValuesInternalKeys(Locale locale, String bundleName, String key,
            Properties currentProperties, boolean overlayEnabled, int recursionLevel, String value) {
        if (recursionLevel > 9) {
            log.warn("Terminating resolving of properties after 10 levels, stopped in bundle::" + bundleName
                    + " and key::" + key);
            return value;
        }
        recursionLevel++;

        StringBuilder resolvedValue = new StringBuilder();
        int lastPos = 0;
        Matcher matcher = resolvingKeyPattern.matcher(value);
        while (matcher.find()) {
            resolvedValue.append(value.substring(lastPos, matcher.start()));
            String toResolvedBundle = matcher.group(1);
            String toResolvedKey = matcher.group(2);
            if (toResolvedBundle == null || toResolvedBundle.equals("")) {
                // $:my.key is valid syntax, points to $current.bundle:my.key
                toResolvedBundle = bundleName;
            }
            if (toResolvedBundle.equals(bundleName) && currentProperties != null) {
                // Resolve within bundle
                String resolvedKey = currentProperties.getProperty(toResolvedKey);
                if (resolvedKey == null) {
                    // Not found, use original (added in next iteration)
                    lastPos = matcher.start();
                } else {
                    resolvedValue.append(resolveValuesInternalKeys(locale, bundleName, toResolvedKey,
                            currentProperties, overlayEnabled, recursionLevel, resolvedKey));
                    lastPos = matcher.end();
                }
            } else {
                // Resolve using other bundle
                String resolvedKey = getLocalizedString(toResolvedBundle, toResolvedKey, null, locale,
                        overlayEnabled, true, true, true, false, recursionLevel);
                if (StringHelper.containsNonWhitespace(resolvedKey)) {
                    resolvedValue.append(resolvedKey);
                    lastPos = matcher.end();
                } else {
                    // Not found, use original (added in next iteration)
                    lastPos = matcher.start();
                }
            }
            // add resolved key to references index
            if (cachingEnabled) {
                String identifyer = buildI18nItemIdentifyer(toResolvedBundle, toResolvedKey);

                Deque<String> referencingBundles = referencingBundlesIndex.get(identifyer);
                if (referencingBundles == null) {
                    Deque<String> newReferencingBundles = new ArrayDeque<>();
                    referencingBundles = referencingBundlesIndex.putIfAbsent(identifyer, newReferencingBundles);
                    if (referencingBundles == null) {
                        referencingBundles = newReferencingBundles;
                    }
                }
                referencingBundles.add(bundleName);

            }
        }
        // Add rest of value
        resolvedValue.append(value.substring(lastPos));
        return resolvedValue.toString();
    }

    /**
     * Create a new property file or update an existing property file form the
     * given propeties object
     * 
     * @param properties The properties to persis
     * @param locale The locale of the properties
     * @param bundleName The properties bundle
     */
    public void saveOrUpdateProperties(Properties properties, Locale locale, String bundleName) {
        String key = calcPropertiesFileKey(locale, bundleName);
        if (isLogDebugEnabled())
            logDebug("saveOrUpdateProperties for key::" + key, null);

        // 1) Save file to disk
        File baseDir = I18nModule.getPropertyFilesBaseDir(locale, bundleName);
        if (baseDir == null) {
            throw new AssertException(
                    "Can not save or update properties file for bundle::" + bundleName + " and language::"
                            + locale.toString() + " - no base directory found, probably loaded from jar!");
        }
        File propertiesFile = getPropertiesFile(locale, bundleName, baseDir);
        OutputStream fileStream = null;
        try {
            // create necessary directories
            File directory = propertiesFile.getParentFile();
            if (!directory.exists())
                directory.mkdirs();
            // write to file file now
            fileStream = new FileOutputStream(propertiesFile);
            properties.store(fileStream, null);
            fileStream.flush();
        } catch (FileNotFoundException e) {
            throw new OLATRuntimeException("Could not save or update to file::" + propertiesFile.getAbsolutePath(),
                    e);
        } catch (IOException e) {
            throw new OLATRuntimeException("Could not save or update to file::" + propertiesFile.getAbsolutePath()
                    + ", maybe permission denied? Check your directory permissions", e);
        } finally {
            try {
                if (fileStream != null)
                    fileStream.close();
            } catch (IOException e) {
                logError("Could not close stream after save or update to file::" + propertiesFile.getAbsolutePath(),
                        e);
            }
        }
        // 2) Check if bundle was already in list of known bundles, add it
        List<String> knownBundles = I18nModule.getBundleNamesContainingI18nFiles();
        if (!knownBundles.contains(bundleName)) {
            knownBundles.add(bundleName);
            Collections.sort(knownBundles);
        }
        // 3) Replace in cache
        // not loaded yet or a non-resolved file (trans-tool)
        if (cachedBundles.containsValue(properties)) {
            // nothing to do with the property, a reused property
            // but remove from javascript translator cache.
            // re-initialization will happen lazy
            if (cachedJSTranslatorData.containsKey(key))
                cachedJSTranslatorData.remove(key);
        } else {
            // Remove existing resolved property first from caches
            if (cachedBundles.containsKey(key))
                cachedBundles.remove(key);
            if (cachedJSTranslatorData.containsKey(key))
                cachedJSTranslatorData.remove(key);
            // Add new version to cache
            if (locale == null) {
                // Add metadata file to cache
                cachedBundles.put(key, properties);
            } else {
                // Getting the resolved property will add it to cache
                getResolvedProperties(locale, bundleName);
            }
        }
    }

    /**
     * Delete the given property file from disk
     * 
     * @param locale
     * @param bundleName
     */
    public void deleteProperties(Locale locale, String bundleName) {
        String key = calcPropertiesFileKey(locale, bundleName);
        if (isLogDebugEnabled())
            logDebug("deleteProperties for key::" + key, null);

        if (locale != null) { // metadata files are not in cache
            // 1) Remove from cache first
            if (cachedBundles.containsKey(key)) {
                cachedBundles.remove(key);
                // Remove also from javascript translator cache.
                // initialization will happen lazy
                if (cachedJSTranslatorData.containsKey(key))
                    cachedJSTranslatorData.remove(key);
            }
        }
        // 2) Remove from filesystem
        File baseDir = I18nModule.getPropertyFilesBaseDir(locale, bundleName);
        if (baseDir == null) {
            if (baseDir == null) {
                throw new AssertException(
                        "Can not delete properties file for bundle::" + bundleName + " and language::"
                                + locale.toString() + " - no base directory found, probably loaded from jar!");
            }
        }
        File f = getPropertiesFile(locale, bundleName, baseDir);
        if (f.exists())
            f.delete();
        // 3) Check if for this bundle any other language file exists, if
        // not remove
        // the bundle from the list of translatable bundles
        List<String> knownBundles = I18nModule.getBundleNamesContainingI18nFiles();
        Set<String> knownLangs = I18nModule.getAvailableLanguageKeys();
        boolean foundOther = false;
        for (String lang : knownLangs) {
            f = getPropertiesFile(getLocaleOrDefault(lang), bundleName, baseDir);
            if (f.exists()) {
                foundOther = true;
                break;
            }
        }
        if (!foundOther) {
            knownBundles.remove(bundleName);
        }

    }

    /**
     * Get the javascript translator data for this locale. The generated code is
     * cached in a VM scope. The entry is removed from the cache whenever
     * something on the property file changed <br>
     * This method should only be called by the JSTranslatorMapper. If you need
     * localized data in your javascript code use the following code snipplet:
     * <code>;
     * &lt;script type='text/javascript'&gt;
     *   var translator = b_jsTranslatorFactory.getTranslator('de', 'org.olat.core');
     *   alert(translator.translate('warn.beta.feature'));
     * &lt;/script&gt;
     * </code>
     * 
     * @param locale
     * @param bundleName
     * @return
     */
    public String getJSTranslatorData(Locale locale, String bundleName) {
        String cacheKey = calcPropertiesFileKey(locale, bundleName);
        // First try to get from cache
        String jsTranslatorData = cachedJSTranslatorData.get(cacheKey);
        // Build the js data if it does not exist yet
        if (jsTranslatorData == null) {
            StringBuilder data = new StringBuilder();
            // we build an js object with key-value pairs
            data.append("var transData = {");
            Locale referenceLocale = I18nModule.getFallbackLocale();
            Properties properties = getPropertiesWithoutResolvingRecursively(referenceLocale, bundleName);
            Set<Object> keys = properties.keySet();
            boolean addComma = false;
            for (Object keyObject : keys) {
                String key = (String) keyObject;
                String value = getLocalizedString(bundleName, key, null, locale, I18nModule.isOverlayEnabled(),
                        true);
                if (value == null) {
                    // use bundlename:key as value in case the key can't be
                    // translated
                    value = buildI18nItemIdentifyer(bundleName, key);
                }
                // remove line breaks and escape double quotes
                value = StringHelper.stripLineBreaks(value);
                value = Formatter.escapeDoubleQuotes(value).toString();
                if (addComma)
                    data.append(",");
                data.append("'").append(key).append("' : \"").append(value).append("\"");
                addComma = true;
            }
            // create a translator in the browser with this data
            data.append("}; b_jsTranslatorFactory._createTranslator(transData,'").append(locale.toString())
                    .append("','").append(bundleName).append("');");
            jsTranslatorData = data.toString();
            // add to cache. don't synchronize, no problem if overwritten
            cachedJSTranslatorData.put(cacheKey, jsTranslatorData);
        }
        return jsTranslatorData;
    }

    public JSONObject getJSONTranslatorData(Locale locale, String bundleName) {
        JSONObject array = new JSONObject();
        Locale referenceLocale = I18nModule.getFallbackLocale();
        Properties properties = getPropertiesWithoutResolvingRecursively(referenceLocale, bundleName);
        Set<Object> keys = properties.keySet();
        for (Object keyObject : keys) {
            String key = (String) keyObject;
            String value = getLocalizedString(bundleName, key, null, locale, I18nModule.isOverlayEnabled(), true);
            if (value == null) {
                // use bundlename:key as value in case the key can't be
                // translated
                value = buildI18nItemIdentifyer(bundleName, key);
            }
            // remove line breaks and escape double quotes
            value = StringHelper.stripLineBreaks(value);
            value = Formatter.escapeDoubleQuotes(value).toString();

            try {
                array.put(key, value);
            } catch (JSONException e) {
                logError("", e);
            }
        }
        return array;
    }

    /**
     * Get the last modified date of this bundle and locale
     * 
     * @param locale
     * @param bundleName
     * @return
     */
    public Long getLastModifiedDate(Locale locale, String bundleName) {
        File baseDir = I18nModule.getPropertyFilesBaseDir(locale, bundleName);
        if (baseDir != null) {
            File propertyFile = getPropertiesFile(locale, bundleName, baseDir);
            return (propertyFile.lastModified());
        } else {
            // must be loaded from a jar, use startup date of VM
            return WebappHelper.getTimeOfServerStartup();
        }
    }

    /**
     * @param localeKey the locale in String form. For the moment we only accept
     *          locales with either a language ("de"), a language and a country
     *          ("de_CH"), or a language and a country and a variant
     *          ("de_CH_bern"). 
     *          <p>
     *          Overlay locales are not not accepted
     *          <p>
     *          If localeKey is null, the default locale is used.
     * @return the locale given the localeKey as returned by the Locale.toString()
     *         method, or the default locale if the language was not found
     */
    public Locale getLocaleOrDefault(String localeKey) {
        if (localeKey == null || !I18nModule.getAvailableLanguageKeys().contains(localeKey)) {
            return I18nModule.getDefaultLocale();
        }
        Locale loc = I18nModule.getAllLocales().get(localeKey);
        if (loc == null)
            loc = I18nModule.getDefaultLocale();
        return loc;
    }

    /**
     * @param localeKey the locale in String form. For the moment we only accept
     *          locales with either a language ("de"), a language and a country
     *          ("de_CH"), or a language and a country and a variant
     *          ("de_CH_bern"). 
     *          <p>
     *          In addition, all overlay locales are also accepted
     *          <p>If localeKey is null, null is returned
     * @return the locale given the localeKey as returned by the Locale.toString()
     *         method, or if no language was found
     */
    public Locale getLocaleOrNull(String localeKey) {
        if (localeKey == null || (!I18nModule.getAvailableLanguageKeys().contains(localeKey)
                && !I18nModule.getOverlayLanguageKeys().contains(localeKey))) {
            return null;
        }
        Locale loc = I18nModule.getAllLocales().get(localeKey);
        return loc;
    }

    /**
     * Translate the given language key to the language itself. This is used in
     * language selection boxes where each language is labeled in their language.
     * This method uses the getLanguageInEnglish() method as a fallback in case
     * the translation could not be found.
     * 
     * @param languageKey
     * @return e.g. "Deutsch" for input de
     */
    public String getLanguageTranslated(String languageKey, boolean overlayEnabled) {
        // Load it from package without fallback
        String translated = null;
        Locale locale = I18nModule.getAllLocales().get(languageKey);
        if (locale != null) {
            translated = getLocalizedString(I18nModule.getCoreFallbackBundle(), "this.language.translated", null,
                    locale, overlayEnabled, false, false, false, 0);
        }
        if (translated == null) {
            // Use the english version as callback
            translated = getLanguageInEnglish(languageKey, overlayEnabled);
        }
        return translated;
    }

    /**
     * Get a map of the enabled language keys as keys with their translated name
     * as values. The language list uses the overlay feature to modify the
     * language name when the overlay is configured.
     * 
     * @return
     */
    public Map<String, String> getEnabledLanguagesTranslated() {
        Collection<String> enabledLangs = I18nModule.getEnabledLanguageKeys();
        Map<String, String> translatedLangs = new HashMap<String, String>(11);
        for (String langKey : enabledLangs) {
            String translated = cachedLangTranslated.get(langKey);
            if (translated == null) {
                String newTranslated = getLanguageTranslated(langKey, I18nModule.isOverlayEnabled());
                translated = cachedLangTranslated.putIfAbsent(langKey, newTranslated);
                if (translated == null) {
                    translated = newTranslated;
                }
            }
            translatedLangs.put(langKey, translated);
        }
        return translatedLangs;
    }

    /**
     * Translate the given language key to english (for administrative interface).
     * This method fallbacks to the language key and never returns an error
     * message
     * 
     * @param languageKey
     * @return e.g. "German" for input de
     */
    public String getLanguageInEnglish(String languageKey, boolean overlayEnabled) {
        // Load it from package without fallback
        String inEnglish = getLocalizedString(I18nModule.getCoreFallbackBundle(), "this.language.in.english", null,
                I18nModule.getAllLocales().get(languageKey), overlayEnabled, false, false, false, 0);
        if (inEnglish == null) {
            // use key as fallback
            inEnglish = languageKey;
        }
        return inEnglish;
    }

    /**
     * Get the authors of the language as they entered themselfes in the
     * translation tool. This reads the key
     * org.olat.core:this.language.translator.names
     * 
     * @param languageKey
     * @return e.g. "Marion Weber, University of Zuerich"
     */
    public String getLanguageAuthor(String languageKey) {
        // Load it from package without fallback
        String authors = getLocalizedString(I18nModule.getCoreFallbackBundle(), "this.language.translator.names",
                null, I18nModule.getAllLocales().get(languageKey), false, false, false, false, 0);
        if (authors == null) {
            return "-";
        }
        return authors;
    }

    /**
     * Create a string array that contains the css markup for country flags
     * 
     * @param languageKeys The source array of language keys
     * @param additionalCssClass additional CSS classes that should be added or
     *          NULL. E.g. you could use 'o_flag'
     * @return
     */
    public String[] createLanguageFlagsCssClasses(String[] languageKeys, String additionalCssClass) {
        String[] flagsCssClasses = new String[languageKeys.length];
        for (int i = 0; i < languageKeys.length; i++) {
            String cssClasses = (additionalCssClass == null ? "" : additionalCssClass);
            String langKey = languageKeys[i];
            cssClasses += " o_flag_" + langKey;
            flagsCssClasses[i] = cssClasses;
        }
        return flagsCssClasses;

    }

    /**
     * Attache some data to thread local variables needed by the i18nManager. Make
     * sure you call remove18nInfoFromThread() when the thread is finished!
     * 
     * @param hreq The http servlet request
     */
    public static void attachI18nInfoToThread(HttpServletRequest hreq) {
        UserSession usess = CoreSpringFactory.getImpl(UserSessionManager.class).getUserSession(hreq);
        if (threadLocalLocale == null) {
            I18nManager.getInstance().logError("can't attach i18n info to thread: threadLocalLocale is null", null);
        } else {
            if (threadLocalLocale.getThreadLocale() != null) {
                I18nManager.getInstance().logWarn(
                        "try to attach i18n info to thread, but threadLocalLocale is not null - a thread forgot to remove it!",
                        new Exception("attachI18nInfoToThread"));
            }
            threadLocalLocale.setThredLocale(usess.getLocale());
        }
        if (threadLocalIsMarkLocalizedStringsEnabled == null) {
            I18nManager.getInstance().logError(
                    "can't attach i18n info to thread: threadLocalIsMarkLocalizedStringsEnabled is null", null);
        } else {
            if (threadLocalIsMarkLocalizedStringsEnabled.isMarkLocalizedStringsEnabled() != null) {
                I18nManager.getInstance().logWarn(
                        "try to attach i18n info to thread, but threadLocalIsMarkLocalizedStringsEnabled is not null - a thread forgot to remove it!",
                        null);
            }
            Boolean isMarkLocalizedStringsEnabled = (Boolean) usess.getEntry(USESS_KEY_I18N_MARK_LOCALIZED_STRINGS);
            if (isMarkLocalizedStringsEnabled != null) {
                threadLocalIsMarkLocalizedStringsEnabled
                        .setMarkLocalizedStringsEnabled(isMarkLocalizedStringsEnabled);
            }
        }
    }

    public static void updateLocaleInfoToThread(UserSession usess) {
        if (threadLocalLocale == null) {
            I18nManager.getInstance().logError("can't attach i18n info to thread: threadLocalLocale is null", null);
        } else {
            threadLocalLocale.setThredLocale(usess.getLocale());
        }
    }

    /**
     * Remove any thread local data that was set using the
     * attachI18nInfoToThread() method
     */
    public static void remove18nInfoFromThread() {
        if (threadLocalLocale != null) {
            threadLocalLocale.setThredLocale(null);
        }
        if (threadLocalIsMarkLocalizedStringsEnabled != null) {
            threadLocalIsMarkLocalizedStringsEnabled.setMarkLocalizedStringsEnabled(null);
        }
    }

    /**
     * Get the locale used in the current thread or the default locale if no
     * locale is set. Usually this is the users logged in Locale
     * 
     * @return the locale of this thread
     */
    public Locale getCurrentThreadLocale() {
        if (threadLocalLocale != null) {
            Locale locale = threadLocalLocale.getThreadLocale();
            if (locale != null)
                return threadLocalLocale.getThreadLocale();
        }
        return I18nModule.getDefaultLocale();
    }

    /**
     * Set the
     * 
     * @param usess
     * @param isMarkLocalizedStringsEnabled
     */
    public void setMarkLocalizedStringsEnabled(UserSession usess, boolean isMarkLocalizedStringsEnabled) {
        // save in user session for later requests
        Boolean markLocalizedStringsEnabled = Boolean.valueOf(isMarkLocalizedStringsEnabled);
        if (usess != null) { // allow null for junit testcases
            usess.putEntry(USESS_KEY_I18N_MARK_LOCALIZED_STRINGS, markLocalizedStringsEnabled);
        }
        // update current thread local variable
        if (threadLocalIsMarkLocalizedStringsEnabled != null) {
            if (isMarkLocalizedStringsEnabled) {
                threadLocalIsMarkLocalizedStringsEnabled
                        .setMarkLocalizedStringsEnabled(markLocalizedStringsEnabled);
            } else {
                threadLocalIsMarkLocalizedStringsEnabled.setMarkLocalizedStringsEnabled(null);
            }
        }
    }

    /**
     * Check if this thread should be rendered using markup arround the localized
     * strings
     * 
     * @return
     */
    public boolean isCurrentThreadMarkLocalizedStringsEnabled() {
        if (threadLocalIsMarkLocalizedStringsEnabled != null) {
            Boolean isMarkLocalizedStringsEnabled = threadLocalIsMarkLocalizedStringsEnabled
                    .isMarkLocalizedStringsEnabled();
            if (isMarkLocalizedStringsEnabled != null)
                return isMarkLocalizedStringsEnabled.booleanValue();
        }
        return false;
    }

    /**
     * Get the priority of the bundle within all bundles. Smaller means higher
     * prio
     * 
     * @param bundleName
     * @return
     */
    int getBundlePriority(String bundleName) {
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        String bundlePrioValue = metadataProperties.getProperty(METADATA_BUNDLE_PRIORITY_KEY);
        if (bundlePrioValue != null) {
            // 1) Bundle priority found, parse and return
            try {
                return (Integer.parseInt(bundlePrioValue.trim()));
            } catch (NumberFormatException e) {
                logWarn("Can not parse metadata priority for bundle::" + bundleName, e);
            }
        }
        // 2) Not found, try with parent bundle
        int dotPos = bundleName.lastIndexOf(".");
        if (dotPos != -1) {
            String parentBundleName = bundleName.substring(0, dotPos);
            return getBundlePriority(parentBundleName);
        }
        // 3) Still not found, use default
        return DEFAULT_BUNDLE_PRIORITY;
    }

    /**
     * Get the priority of this item within the bundle. Smaller means higher prio
     * 
     * @param metadataProperties
     * @param key
     * @param bundleName
     * @return
     */
    int getKeyPriority(Properties metadataProperties, String key, String bundleName) {
        int keyPriority = DEFAULT_KEY_PRIORITY;
        String keyPriorityValue = metadataProperties.getProperty(key + METADATA_KEY_PRIORITY_POSTFIX);
        if (keyPriorityValue != null) {
            try {
                keyPriority = Integer.parseInt(keyPriorityValue.trim());
            } catch (NumberFormatException e) {
                logWarn("Can not parse metadata priority for key::" + bundleName + ":" + key, e);
            }
        }
        return keyPriority;
    }

    /**
     * Set the bundle priority for a specific bundle. Does not update the
     * priorities of the children bundles, but note that when reading priorities
     * of children that do not have a priority set, the system will degregate to
     * the next parent that does have a priority set. Thus, it is not necessary to
     * set children bundles priorities unless you want to set a higher priority.
     * <p>
     * Use priorities as follows:
     * <ul>
     * <li>000 - 099 : ultimate priority, will appear on top of translators list</li>
     * <li>100 - 399 : reserved for olatcore framework package - very high priority
     * </li>
     * <li>400 - 499 : Application bundles: high priority</li>
     * <li>500 - 599 : Application bundles: normal priority</li>
     * <li>600 - 699 : Application bundles: low priority</li>
     * <li>700 - 899 : Extension and custom modules</li>
     * <li>900 - 999 : Examples and demo code - usually no need to translate</li>
     * </ul>
     * If no priority is defined, the standard priority of 500 is used
     * 
     * @param bundleName
     * @param priority
     */
    public void setBundlePriority(String bundleName, int priority) {
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        if (priority > 999) {
            throw new AssertException(
                    "Bundle priorities can not be higher than 999. The smaller the number, the higher the priority.");
        }
        NumberFormat formatter = new DecimalFormat("000");
        metadataProperties.setProperty(METADATA_BUNDLE_PRIORITY_KEY, formatter.format(priority));
        saveOrUpdateProperties(metadataProperties, null, bundleName);
    }

    /**
     * Set the key priority within the bundle. The smaller the number, the higher
     * is the priority. Higher priority items will appear on top of the list of to
     * be translated keys
     * <p>
     * Priorities should be between 000 (hight prio, begin of list) and 999 (low
     * prio, end of list)
     * <p>
     * If no priority is used, the standard priority of 500 is used
     * 
     * @param bundleName
     * @param key
     * @param priority
     */
    public void setKeyPriority(String bundleName, String key, int priority) {
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        if (priority > 999) {
            throw new AssertException(
                    "Bundle priorities can not be higher than 999. The smaller the number, the higher the priority.");
        }
        NumberFormat formatter = new DecimalFormat("00");
        metadataProperties.setProperty(key + METADATA_KEY_PRIORITY_POSTFIX, formatter.format(priority));
        saveOrUpdateProperties(metadataProperties, null, bundleName);
    }

    /**
     * Check if the inline translation mode is possible for a certain key. By
     * default this returns true, only specific keys that have been set to false
     * will return false.
     * 
     * @param metadataProperties
     * @param key
     * @param bundleName
     * @return
     */
    boolean isInlineTranslationEnabledForKey(String bundleName, String key) {
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        String propertyKey = key + METADATA_KEY_INLINEREANSLATION_POSTFIX;
        String keyInlineTranslationValue = metadataProperties.getProperty(propertyKey);
        boolean isEnabled = true; // default
        if (keyInlineTranslationValue != null) {
            keyInlineTranslationValue = keyInlineTranslationValue.toLowerCase();
            if (keyInlineTranslationValue.equals(METADATA_KEY_INLINEREANSLATION_VALUE_DISABLED)
                    || keyInlineTranslationValue.equals("false") || keyInlineTranslationValue.equals("no")) {
                isEnabled = false;
            }
        }
        return isEnabled;
    }

    /**
     * Enable or disable the inline translation mode for a specific key. If
     * disabled, the inline translation mode will not be available for this key.
     * <p>
     * Disable inline translation for keys that have issues with the inline
     * translation system such as default values that are used in forms that
     * have length checks.
     * 
     * @param bundleName
     * @param key
     * @param enable
     */
    public void setInlineTranslationEnabledForKey(String bundleName, String key, boolean enable) {
        Properties metadataProperties = getPropertiesWithoutResolvingRecursively(null, bundleName);
        String propertyKey = key + METADATA_KEY_INLINEREANSLATION_POSTFIX;
        if (enable) {
            if (metadataProperties.contains(propertyKey)) {
                metadataProperties.remove(propertyKey);
            }
        } else {
            metadataProperties.setProperty(propertyKey, METADATA_KEY_INLINEREANSLATION_VALUE_DISABLED);
        }
        saveOrUpdateProperties(metadataProperties, null, bundleName);
    }

    /**
     * Remove all bundles from caches to force reload from filesystem
     */
    void clearCaches() {
        cachedBundles.clear();
        cachedJSTranslatorData.clear();
        referencingBundlesIndex.clear();
    }

    /**
     * Method to enable / disable caching of loaded and resolved bundles
     * 
     * @param useCache
     */
    public void setCachingEnabled(boolean useCache) {
        if (useCache) {
            cachedLangTranslated = new ConcurrentHashMap<String, String>();
            cachedBundles = new ConcurrentHashMap<String, Properties>();
            cachedJSTranslatorData = new ConcurrentHashMap<String, String>();
            referencingBundlesIndex = new ConcurrentHashMap<String, Deque<String>>();
        } else {
            cachedLangTranslated = new AlwaysEmptyMap<String, String>();
            cachedBundles = new AlwaysEmptyMap<String, Properties>();
            cachedJSTranslatorData = new AlwaysEmptyMap<String, String>();
            referencingBundlesIndex = new AlwaysEmptyMap<String, Deque<String>>();
        }
        this.cachingEnabled = useCache;
    }

    /**
     * @return true: manager uses cache; false: manager always reads from
     *         filesystem
     */
    public boolean isCachingEnabled() {
        return this.cachingEnabled;
    }

    /**
     * Helper method to create a locale from a given locale key ('de', 'de_CH',
     * 'de_CH_ZH')
     * 
     * @param localeKey
     * @return the locale or NULL if no locale could be generated from this string
     */
    Locale createLocale(String localeKey) {
        Locale aloc = null;
        // de
        // de_CH
        // de_CH_zueri
        String[] parts = localeKey.split("_");
        switch (parts.length) {
        case 1:
            aloc = new Locale(parts[0]);
            break;
        case 2:
            aloc = new Locale(parts[0], parts[1]);
            break;
        case 3:
            String lastPart = parts[2];
            // Add all remaining parts to variant, variant can contain
            // underscores according to Locale spec
            for (int i = 3; i < parts.length; i++) {
                String part = parts[i];
                lastPart = lastPart + "_" + part;
            }
            aloc = new Locale(parts[0], parts[1], lastPart);
            break;
        default:
            return null;
        }
        // Test if the locale has been constructed correctly. E.g. when the
        // language part is not existing in the ISO chart, the locale can
        // convert to something else.
        // E.g. he_HE_HE will convert automatically to iw_HE_HE
        if (aloc.toString().equals(localeKey)) {
            return aloc;
        } else {
            return null;
        }
    }

    /**
     * Create a local that represents the overlay locale for the given locale
     * 
     * @param locale The original locale
     * @return The overlay locale
     */
    Locale createOverlay(Locale locale) {
        String lang = locale.getLanguage();
        String country = (locale.getCountry() == null ? "" : locale.getCountry());
        String variant = createOverlayKeyForLanguage(locale.getVariant() == null ? "" : locale.getVariant());
        Locale overlay = new Locale(lang, country, variant);
        return overlay;
    }

    /**
     * Add the overlay postfix to the given language key
     * @param langKey
     * @return
     */
    String createOverlayKeyForLanguage(String langKey) {
        return langKey + "__" + I18nModule.getOverlayName();
    }

    /**
     * Helper method to build i18n filenames from a given locale. E.g. when
     * locale=de_CH, the resulting i18n file name will be
     * LocalStrings_de_CH.properties
     * <p>
     * This method will check if the locale is an overlay locale and remove
     * unnecessary white space
     * 
     * @param locale
     * @return
     */
    public String buildI18nFilename(Locale locale) {
        String langKey = getLocaleKey(locale);
        return I18nModule.LOCAL_STRINGS_FILE_PREFIX + langKey + I18nModule.LOCAL_STRINGS_FILE_POSTFIX;
    }

    /**
     * Calculate the locale key that identifies the given locale. Adds support for
     * the overlay mechanism.
     * 
     * @param locale
     * @return
     */
    public String getLocaleKey(Locale locale) {
        String key = localeToLocaleKey.get(locale);
        if (key == null) {
            String langKey = locale.getLanguage();
            String country = locale.getCountry();
            // Only add country when available - in case of an overlay country is
            // set to
            // an empty value
            if (StringHelper.containsNonWhitespace(country)) {
                langKey = langKey + "_" + country;
            }
            String variant = locale.getVariant();
            // Only add the _ separator if the variant contains something in
            // addition to
            // the overlay, otherways use the __ only
            if (StringHelper.containsNonWhitespace(variant)) {
                if (variant.startsWith("__" + I18nModule.getOverlayName())) {
                    langKey += variant;
                } else {
                    langKey = langKey + "_" + variant;
                }
            }

            key = localeToLocaleKey.putIfAbsent(locale, langKey);
            if (key == null) {
                key = langKey;
            }

        }
        return key;
    }

    /**
     * Calculate the language key from the given overlay locale without the locale
     * (the original language before adding the overlay postfix)
     * 
     * @param overlay
     * @return The original language key or NULL if not found
     */
    public String createOrigianlLocaleKeyForOverlay(Locale overlay) {
        Map<Locale, Locale> overlaysLooup = I18nModule.getOverlayLocales();
        Set<Map.Entry<Locale, Locale>> entries = overlaysLooup.entrySet();
        for (Map.Entry<Locale, Locale> entry : entries) {
            if (getLocaleKey(entry.getValue()).equals(getLocaleKey(overlay))) {
                return getLocaleKey(entry.getKey());
            }
        }
        return null;
    }

    /**
     * Helper method to build a unique identifyer for an i18n item from the given
     * bundle name and key
     * 
     * @param bundleName
     * @param key
     * @return
     */
    public String buildI18nItemIdentifyer(String bundleName, String key) {
        return bundleName + ":" + key;
    }

    /**
     * Search in all packages on the source patch for packages that contain an
     * _i18n directory that can be used to store olatcore localization files
     * 
     * @return set of bundles that contain olatcore i18n compatible localization
     *         files
     */
    List<String> searchForBundleNamesContainingI18nFiles() {
        List<String> foundBundles;
        // 1) First search on normal source path of application
        String srcPath = null;
        File applicationDir = I18nModule.getTransToolApplicationLanguagesSrcDir();
        if (applicationDir != null) {
            srcPath = applicationDir.getAbsolutePath();
        } else {
            // Fall back to compiled classes
            srcPath = WebappHelper.getBuildOutputFolderRoot();
        }
        I18nDirectoriesVisitor srcVisitor = new I18nDirectoriesVisitor(srcPath);
        FileUtils.visitRecursively(new File(srcPath), srcVisitor);
        foundBundles = srcVisitor.getBundlesContainingI18nFiles();
        // 3) For jUnit tests, add also the I18n test dir
        if (Settings.isJUnitTest()) {
            Resource testres = new ClassPathResource("olat.local.properties");
            String jUnitSrcPath = null;
            try {
                jUnitSrcPath = testres.getFile().getAbsolutePath();
            } catch (IOException e) {
                throw new StartupException(
                        "Could not find classpath resource for: test-classes/olat.local.property ", e);
            }

            I18nDirectoriesVisitor juniSrcVisitor = new I18nDirectoriesVisitor(jUnitSrcPath);
            FileUtils.visitRecursively(new File(jUnitSrcPath), juniSrcVisitor);
            foundBundles.addAll(juniSrcVisitor.getBundlesContainingI18nFiles());
        }
        // Sort alphabetically
        Collections.sort(foundBundles);
        return foundBundles;
    }

    /**
     * Search for available languages in the given directory. The translation
     * files must start with 'LocalStrings_' and end with '.properties'.
     * Everything in between is considered a language key.
     * <p>
     * If the directory contains jar files, those files are opened and searched
     * for languages files as well. In this case, the algorythm only looks for
     * translation files that are in the org/olat/core/_i18n package
     * 
     * @param i18nDir
     * @return set of language keys the system will find translations for
     */
    Set<String> searchForAvailableLanguages(File i18nDir) {
        Set<String> foundLanguages = new TreeSet<String>();
        i18nDir = new File(i18nDir.getAbsolutePath() + "/org/olat/_i18n");
        if (i18nDir.exists()) {
            // First check for locale files
            String[] langFiles = i18nDir.list(i18nFileFilter);
            for (String langFileName : langFiles) {
                String lang = langFileName.substring(I18nModule.LOCAL_STRINGS_FILE_PREFIX.length(),
                        langFileName.lastIndexOf("."));
                foundLanguages.add(lang);
                if (isLogDebugEnabled())
                    logDebug("Adding lang::" + lang + " from filename::" + langFileName + " from dir::"
                            + i18nDir.getAbsolutePath(), null);
            }
        }
        return foundLanguages;
    }

    /**
     * Searches within a jar file for available languages.
     * 
     * @param jarFile
     * @param checkForExecutables true: check if jar contains java or class files
     *          and return an empty set if such executable files are found; false
     *          don't check or care
     * @return Set of language keys, can be empty but never null
     */
    public Set<String> sarchForAvailableLanguagesInJarFile(File jarFile, boolean checkForExecutables) {
        Set<String> foundLanguages = new TreeSet<String>();
        JarFile jar = null;
        try {
            jar = new JarFile(jarFile);
            Enumeration<JarEntry> jarEntries = jar.entries();
            while (jarEntries.hasMoreElements()) {
                JarEntry jarEntry = jarEntries.nextElement();
                String jarEntryName = jarEntry.getName();
                // check for executables
                if (checkForExecutables && (jarEntryName.endsWith("java") || jarEntryName.endsWith("class"))) {
                    return new TreeSet<String>();
                }
                // search for core util in jar
                if (jarEntryName
                        .indexOf(I18nModule.getCoreFallbackBundle().replace(".", "/") + "/" + I18N_DIRNAME) != -1) {
                    // don't add overlayLocales as selectable
                    // availableLanguages
                    if (jarEntryName.indexOf("__") == -1
                            && jarEntryName.indexOf(I18nModule.LOCAL_STRINGS_FILE_PREFIX) != -1) {
                        String lang = jarEntryName.substring(
                                jarEntryName.indexOf(I18nModule.LOCAL_STRINGS_FILE_PREFIX)
                                        + I18nModule.LOCAL_STRINGS_FILE_PREFIX.length(),
                                jarEntryName.lastIndexOf("."));
                        foundLanguages.add(lang);
                        if (isLogDebugEnabled())
                            logDebug("Adding lang::" + lang + " from filename::" + jarEntryName + " in jar::"
                                    + jar.getName(), null);
                    }
                }
            }
        } catch (IOException e) {
            throw new OLATRuntimeException("Error when looking up i18n files in jar::" + jarFile.getAbsolutePath(),
                    e);
        } finally {
            IOUtils.closeQuietly(jar);
        }
        return foundLanguages;
    }

    /**
     * Copy the given set of languages from the given jar to the configured i18n
     * source directories. This method can only be called in a translation
     * server environment.
     * 
     * @param jarFile
     * @param toCopyI18nKeys
     */
    public void copyLanguagesFromJar(File jarFile, Collection<String> toCopyI18nKeys) {
        if (!I18nModule.isTransToolEnabled()) {
            throw new AssertException(
                    "Programming error - can only copy i18n files from a language pack to the source when in translation mode");
        }
        JarFile jar = null;
        try {
            jar = new JarFile(jarFile);
            Enumeration<JarEntry> jarEntries = jar.entries();
            while (jarEntries.hasMoreElements()) {
                JarEntry jarEntry = jarEntries.nextElement();
                String jarEntryName = jarEntry.getName();
                // Check if this entry is a language file
                for (String i18nKey : toCopyI18nKeys) {
                    if (jarEntryName.endsWith(I18N_DIRNAME + "/" + I18nModule.LOCAL_STRINGS_FILE_PREFIX + i18nKey
                            + I18nModule.LOCAL_STRINGS_FILE_POSTFIX)) {
                        File targetBaseDir;
                        if (i18nKey.equals("de") || i18nKey.equals("en")) {
                            targetBaseDir = I18nModule.getTransToolApplicationLanguagesSrcDir();
                        } else {
                            targetBaseDir = I18nModule.getTransToolApplicationOptLanguagesSrcDir();
                        }
                        // Copy file
                        File targetFile = new File(targetBaseDir, jarEntryName);
                        targetFile.getParentFile().mkdirs();
                        FileUtils.save(jar.getInputStream(jarEntry), targetFile);
                        // Check that saved properties file is empty, if so remove it 
                        Properties props = new Properties();
                        props.load(new FileInputStream(targetFile));
                        if (props.size() == 0) {
                            targetFile.delete();
                            // Delete empty parent dirs recursively
                            File parent = targetFile.getParentFile();
                            while (parent != null && parent.list() != null && parent.list().length == 0) {
                                parent.delete();
                                parent = parent.getParentFile();
                            }
                        }
                        // Continue with next jar entry
                        break;
                    }
                }
            }
        } catch (IOException e) {
            throw new OLATRuntimeException(
                    "Error when copying up i18n files from a jar::" + jarFile.getAbsolutePath(), e);
        } finally {
            IOUtils.closeQuietly(jar);
        }
    }

    /**
     * Get the property file for a given locale and bundle. If the locale is null,
     * the metadata for this bundle are returned instead.
     * 
     * @param locale the locale or NULL to get the bundle metadata file
     * @param bundleName
     * @param sourceDir the source directory where to search for the properties
     *          file
     * @return a file object. The file might not exist, but the mehod never return
     *         NULL!
     */
    public File getPropertiesFile(Locale locale, String bundleName, File sourceDir) {
        if (bundleName == null)
            throw new AssertException("getPropertyFile(): bundleName can not be null");
        if (sourceDir == null)
            throw new AssertException("getPropertyFile(): sourceDir can not be null");
        // Create relative path to sourceDir
        bundleName = bundleName.replace('.', '/');
        String fileName = (locale == null ? METADATA_FILENAME : buildI18nFilename(locale));
        String relPath = "/" + bundleName + "/" + I18N_DIRNAME + "/" + fileName;
        // Load file from path
        File f = new File(sourceDir, relPath);
        if (f.exists() || I18nModule.isTransToolEnabled()) {
            return f;
        }
        return f;
    }

    public boolean createNewLanguage(String localeKey, String languageInEnglish, String languageTranslated,
            String authors) {
        if (!I18nModule.isTransToolEnabled()) {
            throw new AssertException(
                    "Can not create a new language when the translation tool is not enabled and the transtool source pathes are not configured! Check your olat.properties files");
        }
        if (I18nModule.getAvailableLanguageKeys().contains(localeKey)) {
            return false;
        }
        // Create new property file in the brasato bundle and re-initialize
        // everything
        String coreFallbackBundle = I18nModule.getApplicationFallbackBundle();
        File transToolCoreLanguagesDir = I18nModule.getTransToolApplicationOptLanguagesSrcDir();
        String i18nDirRelPath = "/" + coreFallbackBundle.replace(".", "/") + "/" + I18nManager.I18N_DIRNAME;
        File transToolCoreLanguagesDir_I18n = new File(transToolCoreLanguagesDir, i18nDirRelPath);
        File newPropertiesFile = new File(transToolCoreLanguagesDir_I18n,
                I18nModule.LOCAL_STRINGS_FILE_PREFIX + localeKey + I18nModule.LOCAL_STRINGS_FILE_POSTFIX);
        // Prepare property file
        // Use a sorted properties object that saves the keys sorted alphabetically to disk
        Properties newProperties = new SortedProperties();
        if (StringHelper.containsNonWhitespace(languageInEnglish)) {
            newProperties.setProperty("this.language.in.english", languageInEnglish);
        }
        if (StringHelper.containsNonWhitespace(languageTranslated)) {
            newProperties.setProperty("this.language.translated", languageTranslated);
        }
        if (StringHelper.containsNonWhitespace(authors)) {
            newProperties.setProperty("this.language.translator.names", authors);
        }

        OutputStream fileStream = null;
        try {
            // Create necessary directories
            File directory = newPropertiesFile.getParentFile();
            if (!directory.exists())
                directory.mkdirs();
            // Write to file file now
            fileStream = new FileOutputStream(newPropertiesFile);
            newProperties.store(fileStream, null);
            fileStream.flush();
            // Now set new language as enabled to allow user to translate the language. 
            Collection<String> enabledLangKeys = I18nModule.getEnabledLanguageKeys();
            enabledLangKeys.add(localeKey);
            // Reinitialize languages with new language
            I18nModule.reInitializeAndFlushCache();
            // Now add new language as new language (will re-initialize everything a second time)
            I18nModule.setEnabledLanguageKeys(enabledLangKeys);
            return true;

        } catch (FileNotFoundException e) {
            throw new OLATRuntimeException(
                    "Could not create new language file::" + newPropertiesFile.getAbsolutePath(), e);
        } catch (IOException e) {
            throw new OLATRuntimeException(
                    "Could not create new language file::" + newPropertiesFile.getAbsolutePath()
                            + ", maybe permission denied? Check your directory permissions",
                    e);
        } finally {
            try {
                if (fileStream != null)
                    fileStream.close();
            } catch (IOException e) {
                logError("Could not close stream after creating new language file::"
                        + newPropertiesFile.getAbsolutePath(), e);
            }
        }
    }

    /**
     * Method to delete an entire language.
     * 
     * @param deleteLangKey
     * @param true: really delete the language; false: dry run with console
     *        logging
     */
    public void deleteLanguage(String deleteLangKey, boolean reallyDeleteIt) {
        Locale deleteLoclae = I18nModule.getAllLocales().get(deleteLangKey);
        // copy bundles list to prevent concurrent modification exception
        List<String> bundlesCopy = new ArrayList<String>();
        bundlesCopy.addAll(I18nModule.getBundleNamesContainingI18nFiles());
        for (String bundleName : bundlesCopy) {
            if (reallyDeleteIt) {
                deleteProperties(deleteLoclae, bundleName);
                logDebug("Deleted bundle::" + bundleName + " and lang::" + deleteLangKey, null);
            } else {
                // just log
                logInfo("Dry-run-delete of bundle::" + bundleName + " and lang::" + deleteLangKey, null);
            }
        }
        // Now reinitialize everything
        if (reallyDeleteIt) {
            I18nModule.reInitializeAndFlushCache();
        }
    }

    /**
     * Create a jar file that contains the translations for the given languages.
     * <p>
     * Note that this file is created in a temporary place in olatdata/tmp. It is
     * in the responsibility of the caller of this method to remove the file when
     * not needed anymore.
     * 
     * @param languageKeys
     * @param fileName the name of the file.
     * @return The file handle to the created file or NULL if no such file could
     *         be created (e.g. there already exists a file with this file name)
     */
    public File createLanguageJarFile(Collection<String> languageKeys, String fileName) {
        // Create file olatdata temporary directory
        File file = new File(WebappHelper.getTmpDir() + "/" + fileName);
        file.getParentFile().mkdirs();

        FileOutputStream stream = null;
        JarOutputStream out = null;
        try {
            // Open stream for jar file
            stream = new FileOutputStream(file);
            out = new JarOutputStream(stream, new Manifest());
            // Use now as last modified date of resources
            long now = System.currentTimeMillis();
            // Add all languages
            for (String langKey : languageKeys) {
                Locale locale = getLocaleOrNull(langKey);
                // Add all bundles in the current language
                for (String bundleName : I18nModule.getBundleNamesContainingI18nFiles()) {
                    Properties propertyFile = getPropertiesWithoutResolvingRecursively(locale, bundleName);
                    String entryFileName = bundleName.replace(".", "/") + "/" + I18N_DIRNAME + "/"
                            + buildI18nFilename(locale);
                    // Create jar entry for this path, name and last modified
                    JarEntry jarEntry = new JarEntry(entryFileName);
                    jarEntry.setTime(now);
                    // Write properties to jar file
                    out.putNextEntry(jarEntry);
                    propertyFile.store(out, null);
                    if (isLogDebugEnabled()) {
                        logDebug("Adding file::" + entryFileName + " + to jar", null);
                    }
                }
            }
            logDebug("Finished writing jar file::" + file.getAbsolutePath(), null);
        } catch (Exception e) {
            logError("Could not write jar file", e);
            return null;
        } finally {
            try {
                out.close();
                stream.close();
            } catch (IOException e) {
                logError("Could not close stream of jar file", e);
                return null;
            }
        }
        return file;
    }

    /*************************
     * Private helper methods
     *************************/

    /**
     * [used by spring]
     */
    private I18nManager() {
        INSTANCE = this;
    }

    /**
     * Helper method to create a key that uniquely identifies a property file
     * within the whole system using the bundle name and the locale. If the locale
     * is null, the metadata for this bundle are returned
     * 
     * @param locale The locale or NULL to create the bundle metadata key
     * @param bundleName
     * @return a unique key as String
     */
    private String calcPropertiesFileKey(Locale locale, String bundleName) {
        if (locale == null) {
            return bundleName + ":" + METADATA_KEY;
        } else {
            return bundleName + ":" + getLocaleKey(locale);
        }
    }

    /**
     * Description:<br>
     * A per-thread Locale that is used to translate messages that don't
     * explicitly provide a locale
     * <P>
     * Initial Date: 19.09.2008 <br>
     * 
     * @author gnaegi
     */
    private static class ThreadLocalLocale extends ThreadLocal<Locale> {
        /**
         * @see java.lang.ThreadLocal#initialValue()
         */
        public Locale initialValue() {
            return null;
        }

        /**
         * @param threadLocale The thread locale
         */
        public void setThredLocale(Locale threadLocale) {
            if (threadLocale == null) {
                super.remove();
            } else {
                super.set(threadLocale);
            }
        }

        /**
         * @return the thread locale
         */
        public Locale getThreadLocale() {
            return super.get();
        }

    }

    /**
     * 
     * Description:<br>
     * A per-thread Boolean to enable or disable markup around localized strings
     * <P>
     * Initial Date: 19.09.2008 <br>
     * 
     * @author gnaegi
     */
    private static class ThreadLocalMarkLocalizedStrings extends ThreadLocal<Boolean> {
        /**
         * @see java.lang.ThreadLocal#initialValue()
         */
        public Boolean initialValue() {
            return null;
        }

        /**
         * @param markLocalizedStrings true: add markup to localized strings; false:
         *          do normal translate strings
         */
        public void setMarkLocalizedStringsEnabled(Boolean markLocalizedStrings) {
            if (markLocalizedStrings == null) {
                super.remove();
            } else {
                super.set(markLocalizedStrings);
            }
        }

        /**
         * @return true: add markup to localized strings; false: do normal translate
         *         strings
         */
        public Boolean isMarkLocalizedStringsEnabled() {
            return super.get();
        }

    }

}