ResourceBundleTranslator.java

/*
 * Copyright (c) 2023. Roland T. Lichti, Kaiserpfalz EDV-Service.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

package de.kaiserpfalzedv.commons.core.i18n;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.stream.Collectors;

import de.kaiserpfalzedv.commons.api.i18n.MessageSource;
import de.kaiserpfalzedv.commons.api.i18n.NoSuchMessageException;
import de.kaiserpfalzedv.commons.api.i18n.Translator;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;

/**
 * Translator -- Provides a nice way to read translations from Resource bundles.
 *
 * @author klenkes74 {@literal <rlichti@kaiserpfalz-edv.de>}
 * @since 0.1.0  2021-03-27
 */
@Slf4j
public class ResourceBundleTranslator implements Translator, MessageSource {
    private static final long serialVersionUID = 0L;

    /**
     * The languages this class provides. To enable testing, a setter is provided. Normally it will be configured via
     * property with a default of "de,en,fr,nl,es,it".
     */
    @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "lombok generated setter")
    @Setter
    @Getter
    private List<String> configuredLanguages = List.of("de","en","fr","nl","es","it");

    /**
     * Default bundle to use when no other bundle is selected.
     */
    private final String defaultBundle;

    /**
     * The default locale (used when no locale is specified in the translation call). To enable testing, a setter is
     * provided. Normally it will be configured via property "default-locale" with a default of "de".
     */
    @Setter
    @Getter
    private String defaultLocale = "de";

    private final transient HashMap<String, HashMap<Locale, ResourceBundle>> bundles = new HashMap<>();


    public ResourceBundleTranslator() {
        this("messages");
    }

    public ResourceBundleTranslator(final String defaultBundle) {
        this.defaultBundle = defaultBundle;
    }


    @SuppressWarnings("RedundantThrows")
    @Override
    public String getMessage(final String key, final Object[] params, final Locale locale) throws NoSuchMessageException {
        return this.getTranslation(key, locale, params);
    }

    @Override
    public String getTranslation(final String key, final Locale locale, final Object... arguments) {
        return this.getTranslation(this.defaultBundle, key, locale, arguments);
    }

    @Override
    public String getTranslation(final Object bundleObject, final String key, final Locale locale, final Object... arguments) {
        final String bundleName = bundleObject.getClass().getCanonicalName()
                .replace(".", "/")
                .replace("_Subclass", ""); // get around for lombok or both or so. :-(

        return this.getTranslation(bundleName, key, locale, arguments);
    }

    @SuppressFBWarnings(value = "DCN_NULLPOINTER_EXCEPTION", justification = "It's the most elegant way.")
    @Override
    public String getTranslation(final String bundleName, final String key, final Locale locale, final Object... arguments) {
        this.loadBundle(bundleName, locale);
        try {
            final String pattern = this.bundles.get(bundleName).get(locale).getString(key);
            final MessageFormat format = new MessageFormat(pattern, locale);
            return format.format(arguments);
        } catch (NullPointerException | MissingResourceException ex) {
            log.warn(
                    "Translation failed. bundle={}, locale={}, key={}",
                    bundleName, locale, key
            );
            return "!" + key;
        }
    }



    /**
     * Loads the bundle into the cache.
     *
     * @param bundleName The base filename for the translation bundle.
     * @param locale     The locale to load the bundle for.
     */
    @SuppressFBWarnings(value = "DCN_NULLPOINTER_EXCEPTION", justification = "It's the most elegant way.")
    private void loadBundle(final String bundleName, Locale locale) {
        if (!this.bundles.containsKey(bundleName)) {
            log.debug("Adding bundle. baseName='{}'", bundleName);

            this.bundles.put(bundleName, new HashMap<>());
        }

        if (locale == null) {
            locale = Locale.forLanguageTag(this.defaultLocale);
        }

        if (!this.bundles.get(bundleName).containsKey(locale)) {
            log.info("Loading bundle. baseName='{}', locale='{}'", bundleName, locale.getDisplayName());

            ResourceBundle bundle;
            try {
                bundle = ResourceBundle.getBundle(bundleName, locale, new UnicodeResourceBundleControl());
            } catch (NullPointerException | MissingResourceException e) {
                final Locale l = Locale.forLanguageTag(locale.getLanguage());

                log.warn("Translator did not find the wanted locale for the bundle. bundle={}, locale={}, orig.locale={}, error='{}'",
                        bundleName, l, locale, e.getMessage());
                try {
                    bundle = ResourceBundle.getBundle(bundleName, l, new UnicodeResourceBundleControl());
                } catch (NullPointerException | MissingResourceException e1) {
                    log.warn("Translator did not find the wanted bundle. Using default bundle. bundle={}, error='{}'", bundleName, e1.getMessage());

                    try {
                        bundle = ResourceBundle.getBundle(this.defaultBundle, Locale.forLanguageTag(this.defaultLocale),
                                new UnicodeResourceBundleControl());
                    } catch (NullPointerException | MissingResourceException e2) {
                        log.error("Resource bundle can't be read. error='{}'", e2.getMessage());

                        return;
                    }
                }
            }
            this.bundles.get(bundleName).put(locale, bundle);
        }
    }

    @Override
    public void close() {
        log.info("Closing all bundles.");
        this.bundles.clear();
    }

    @Override
    public List<Locale> getProvidedLocales() {
        return this.configuredLanguages.stream()
                .map(Locale::forLanguageTag)
                .filter(d -> {log.trace("Mapped language. locale={}", d); return true;})
                .collect(Collectors.toList());
    }

    /**
     * @author peholmst
     * @since 0.1.0
     */
    private static class UnicodeResourceBundleControl extends ResourceBundle.Control {
        @Override
        public ResourceBundle newBundle(
                final String baseName,
                final Locale locale,
                final String format,
                final ClassLoader loader,
                final boolean reload
        ) throws IOException {
            final ClassLoader used = Thread.currentThread().getContextClassLoader();

            log.debug("Classloader will be ignored. used={}, ignored={}", used, loader);

            final String bundleName = this.toBundleName(baseName, locale);
            final String resourceName = this.toResourceName(bundleName, "properties");
            final URL resourceURL = used.getResource(resourceName);
            if (resourceURL == null)
                return null;

            try (BufferedReader in = new BufferedReader(new InputStreamReader(resourceURL.openStream(), StandardCharsets.UTF_8))) {
                return new PropertyResourceBundle(in);
            }
        }
    }
}