001/*
002 * PermissionsEx
003 * Copyright (C) zml and PermissionsEx contributors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *    http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package ca.stellardrift.permissionsex.util;
018
019import net.kyori.adventure.key.Key;
020import net.kyori.adventure.text.Component;
021import net.kyori.adventure.text.ComponentLike;
022import net.kyori.adventure.text.TranslatableComponent;
023import net.kyori.adventure.translation.GlobalTranslator;
024import net.kyori.adventure.translation.TranslationRegistry;
025import net.kyori.adventure.translation.Translator;
026import net.kyori.adventure.util.UTF8ResourceBundleControl;
027import org.checkerframework.checker.nullness.qual.NonNull;
028import org.checkerframework.checker.nullness.qual.Nullable;
029import org.slf4j.Logger;
030import org.slf4j.LoggerFactory;
031
032import java.io.IOException;
033import java.nio.file.Files;
034import java.nio.file.Path;
035import java.nio.file.Paths;
036import java.text.MessageFormat;
037import java.util.Collections;
038import java.util.HashSet;
039import java.util.Locale;
040import java.util.ResourceBundle;
041import java.util.Set;
042import java.util.stream.Collectors;
043import java.util.stream.Stream;
044import java.util.zip.ZipEntry;
045import java.util.zip.ZipInputStream;
046
047import static java.util.Objects.requireNonNull;
048
049/**
050 * A typesafe provider for translatable messages.
051 *
052 * <p>Designed for use from generated code containing translation keys.</p>
053 *
054 * @since 2.0.0
055 */
056public final class TranslatableProvider implements ComponentLike {
057    private static final Logger LOGGER = LoggerFactory.getLogger("PermissionsEx Translations");
058    private static final String EXPECTED_EXTENSION = ".properties";
059    private static final Locale DEFAULT_LOCALE = Locale.ENGLISH;
060    private static @Nullable Path lastCodeSource;
061    private static @Nullable Set<String> lastKnownResourceBundles;
062
063    private final String key;
064
065    /**
066     * Create a new translatable provider
067     * @param bundle the name of the {@link java.util.ResourceBundle} containing the message
068     * @param key the translation key
069     */
070    public TranslatableProvider(final String bundle, final String key) {
071        requireNonNull(bundle, "bundle");
072        requireNonNull(key, "key");
073        this.key = bundle + '/' + key;
074    }
075
076    /**
077     * The translation key used for lookup.
078     *
079     * @return the translation key
080     * @since 2.0.0
081     */
082    public String key() {
083        return this.key;
084    }
085
086    /**
087     * Create a translatable component with the provided arguments.
088     *
089     * @param args the arguments
090     * @return a new translatable component
091     * @since 2.0.0
092     */
093    public TranslatableComponent tr(final Object... args) {
094        return Component.translatable(this.key, this.transformArray(args));
095    }
096
097    /**
098     * Create a translatable component builder configured with the provided arguments.
099     *
100     * @param args the arguments
101     * @return a new builder
102     * @since 2.0.0
103     */
104    public TranslatableComponent.Builder bTr(final Object... args) {
105        return Component.translatable().key(this.key).args(this.transformArray(args));
106    }
107
108    private Component[] transformArray(final Object[] input) {
109        final Component[] output = new Component[input.length];
110        for (int i = 0, length = input.length; i < length; ++i) {
111            output[i] = asComponent(input[i]);
112        }
113        return output;
114    }
115
116    private Component asComponent(final Object input) {
117        if (input instanceof Component) {
118            return (Component) input;
119        } else if (input instanceof ComponentLike) {
120            return ((ComponentLike) input).asComponent();
121        } else {
122            return Component.text(String.valueOf(input));
123        }
124    }
125
126    /**
127     * {@inheritDoc}
128     *
129     * <p>This will create a component without any arguments.</p>
130     */
131    @Override
132    public @NonNull Component asComponent() {
133        return this.tr();
134    }
135
136    /**
137     * Get known locales for a certain bundle name.
138     *
139     * @param loaderOf The class to use to determine a code source
140     * @param bundleName the name of the bundle to find languages of
141     * @return the known locales
142     */
143    public static synchronized Stream<Locale> knownLocales(final Class<?> loaderOf, final String bundleName) {
144        // Get all resources from the same code source as the class
145        // If the code source is a jar: open the jar, iterate through entries?
146        try {
147            final Path codeSource = Paths.get(loaderOf.getProtectionDomain().getCodeSource().getLocation().toURI());
148            final String bundlePathName = bundleName.replace('.', '/');
149            final Set<String> paths;
150            if (!codeSource.equals(lastCodeSource)) {
151                // If the code source is a directory: visit the path
152                final String fileName = codeSource.getFileName().toString();
153                if (Files.isDirectory(codeSource)) {
154                    try (final Stream<Path> files = Files.walk(codeSource)) {
155                        paths = files.filter(it -> it.endsWith(EXPECTED_EXTENSION)) // only properties files
156                            .map(it -> it.relativize(codeSource)) // relative path
157                            .map(it -> it.toString().replace('\\', '/')) // with consistent slashes
158                            .collect(Collectors.toSet());
159                    }
160                } else if (fileName.endsWith("jar") || fileName.endsWith("zip")) {
161                    // read from the archive
162                    paths = new HashSet<>();
163                    try (final ZipInputStream is = new ZipInputStream(Files.newInputStream(codeSource))) {
164                        @Nullable ZipEntry entry;
165                        while ((entry = is.getNextEntry()) != null) {
166                            if (entry.getName().endsWith(EXPECTED_EXTENSION)) {
167                                paths.add(entry.getName());
168                            }
169                        }
170                    }
171                } else {
172                    throw new IOException("Unknown file type " + codeSource);
173                }
174                lastCodeSource = codeSource;
175                lastKnownResourceBundles = Collections.unmodifiableSet(paths);
176            } else {
177                paths = requireNonNull(lastKnownResourceBundles, "has been set");
178            }
179
180            return paths.stream()
181                .filter(it -> it.startsWith(bundlePathName))
182                .map(it -> it.substring(bundlePathName.length(), it.length() - EXPECTED_EXTENSION.length()))
183                .filter(it -> it.isEmpty() || it.startsWith("_")) // either default file, or for a language
184                .map(it -> {
185                    if (it.isEmpty()) {
186                        return DEFAULT_LOCALE;
187                    } else {
188                        return Translator.parseLocale(it.substring(1));
189                    }
190                });
191        } catch (final Exception ex) {
192            LOGGER.error("Failed to read known locales for bundle {}", bundleName, ex);
193        }
194
195        // Fallback
196        return Stream.of(DEFAULT_LOCALE);
197    }
198
199    public static void registerAllTranslations(final String bundleName, final Stream<Locale> languages, final ClassLoader loader) {
200        final TranslationRegistry registry = TranslationRegistry.create(Key.key("permissionsex", bundleName.toLowerCase(Locale.ROOT)));
201        registry.defaultLocale(DEFAULT_LOCALE);
202
203        languages.forEach(language  -> {
204            final ResourceBundle bundle = ResourceBundle.getBundle(
205                bundleName,
206                language,
207                loader,
208                UTF8ResourceBundleControl.get()
209            );
210
211            for (final String key : bundle.keySet()) {
212                try {
213                    registry.register(formatKey(bundleName, key), language, new MessageFormat(bundle.getString(key)));
214                } catch (final Exception ex) {
215                    LOGGER.warn("Failed to register translation key {} in bundle {}", key, bundleName, ex);
216                }
217            }
218        });
219
220        GlobalTranslator.get().addSource(registry);
221    }
222
223    private static String formatKey(final String bundleName, final String key) {
224        return bundleName + '/' + key;
225    }
226}