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.net.URL;
034import java.nio.file.Files;
035import java.nio.file.Path;
036import java.nio.file.Paths;
037import java.text.MessageFormat;
038import java.util.Collections;
039import java.util.HashSet;
040import java.util.Locale;
041import java.util.ResourceBundle;
042import java.util.Set;
043import java.util.regex.Pattern;
044import java.util.stream.Collectors;
045import java.util.stream.Stream;
046import java.util.zip.ZipEntry;
047import java.util.zip.ZipInputStream;
048
049import static java.util.Objects.requireNonNull;
050
051/**
052 * A typesafe provider for translatable messages.
053 *
054 * <p>Designed for use from generated code containing translation keys.</p>
055 *
056 * @since 2.0.0
057 */
058public final class TranslatableProvider implements ComponentLike {
059    private static final Logger LOGGER = LoggerFactory.getLogger("PermissionsEx Translations");
060    private static final String EXPECTED_EXTENSION = ".properties";
061    private static final Locale DEFAULT_LOCALE = Locale.ENGLISH;
062    private static final Pattern SINGLE_QUOTE_PATTERN = Pattern.compile("'");
063    private static @Nullable Path lastCodeSource;
064    private static @Nullable Set<String> lastKnownResourceBundles;
065
066    private final String key;
067
068    /**
069     * Create a new translatable provider
070     * @param bundle the name of the {@link java.util.ResourceBundle} containing the message
071     * @param key the translation key
072     */
073    public TranslatableProvider(final String bundle, final String key) {
074        requireNonNull(bundle, "bundle");
075        requireNonNull(key, "key");
076        this.key = bundle + '/' + key;
077    }
078
079    /**
080     * The translation key used for lookup.
081     *
082     * @return the translation key
083     * @since 2.0.0
084     */
085    public String key() {
086        return this.key;
087    }
088
089    /**
090     * Create a translatable component with the provided arguments.
091     *
092     * @param args the arguments
093     * @return a new translatable component
094     * @since 2.0.0
095     */
096    public TranslatableComponent tr(final Object... args) {
097        return Component.translatable(this.key, this.transformArray(args));
098    }
099
100    /**
101     * Create a translatable component builder configured with the provided arguments.
102     *
103     * @param args the arguments
104     * @return a new builder
105     * @since 2.0.0
106     */
107    public TranslatableComponent.Builder bTr(final Object... args) {
108        return Component.translatable().key(this.key).args(this.transformArray(args));
109    }
110
111    private Component[] transformArray(final Object[] input) {
112        final Component[] output = new Component[input.length];
113        for (int i = 0, length = input.length; i < length; ++i) {
114            output[i] = asComponent(input[i]);
115        }
116        return output;
117    }
118
119    private Component asComponent(final Object input) {
120        if (input instanceof Component) {
121            return (Component) input;
122        } else if (input instanceof ComponentLike) {
123            return ((ComponentLike) input).asComponent();
124        } else {
125            return Component.text(String.valueOf(input));
126        }
127    }
128
129    /**
130     * {@inheritDoc}
131     *
132     * <p>This will create a component without any arguments.</p>
133     */
134    @Override
135    public @NonNull Component asComponent() {
136        return this.tr();
137    }
138
139    /**
140     * Get known locales for a certain bundle name.
141     *
142     * @param loaderOf The class to use to determine a code source
143     * @param bundleName the name of the bundle to find languages of
144     * @return the known locales
145     */
146    public static synchronized Stream<Locale> knownLocales(final Class<?> loaderOf, final String bundleName) {
147        // Get all resources from the same code source as the class
148        // If the code source is a jar: open the jar, iterate through entries?
149        try {
150            URL sourceUrl = loaderOf.getProtectionDomain().getCodeSource().getLocation();
151            // Some class loaders give the full url to the class, some give the URL to its jar.
152            // We want the containing jar, so we will unwrap jar-schema code sources.
153            if (sourceUrl.getProtocol().equals("jar")) {
154                final int exclamationIdx = sourceUrl.getPath().lastIndexOf('!');
155                if (exclamationIdx != -1) {
156                    sourceUrl = new URL(sourceUrl.getPath().substring(0, exclamationIdx));
157                }
158            }
159            final Path codeSource = Paths.get(sourceUrl.toURI());
160            final String bundlePathName = bundleName.replace('.', '/');
161            final Set<String> paths;
162            if (!codeSource.equals(lastCodeSource)) {
163                // If the code source is a directory: visit the path
164                final String fileName = codeSource.getFileName().toString();
165                if (Files.isDirectory(codeSource)) {
166                    try (final Stream<Path> files = Files.walk(codeSource)) {
167                        paths = files.filter(it -> it.endsWith(EXPECTED_EXTENSION)) // only properties files
168                            .map(it -> it.relativize(codeSource)) // relative path
169                            .map(it -> it.toString().replace('\\', '/')) // with consistent slashes
170                            .collect(Collectors.toSet());
171                    }
172                } else if (fileName.endsWith("jar") || fileName.endsWith("zip")) {
173                    // read from the archive
174                    paths = new HashSet<>();
175                    try (final ZipInputStream is = new ZipInputStream(Files.newInputStream(codeSource))) {
176                        @Nullable ZipEntry entry;
177                        while ((entry = is.getNextEntry()) != null) {
178                            if (entry.getName().endsWith(EXPECTED_EXTENSION)) {
179                                paths.add(entry.getName());
180                            }
181                        }
182                    }
183                } else {
184                    throw new IOException("Unknown file type " + codeSource);
185                }
186                lastCodeSource = codeSource;
187                lastKnownResourceBundles = Collections.unmodifiableSet(paths);
188            } else {
189                paths = requireNonNull(lastKnownResourceBundles, "has been set");
190            }
191
192            return paths.stream()
193                .filter(it -> it.startsWith(bundlePathName))
194                .map(it -> it.substring(bundlePathName.length(), it.length() - EXPECTED_EXTENSION.length()))
195                .filter(it -> it.isEmpty() || it.startsWith("_")) // either default file, or for a language
196                .map(it -> {
197                    if (it.isEmpty()) {
198                        return DEFAULT_LOCALE;
199                    } else {
200                        return Translator.parseLocale(it.substring(1));
201                    }
202                });
203        } catch (final Exception ex) {
204            LOGGER.error("Failed to read known locales for bundle {}", bundleName, ex);
205        }
206
207        // Fallback
208        return Stream.of(DEFAULT_LOCALE);
209    }
210
211    public static void registerAllTranslations(final String bundleName, final Stream<Locale> languages, final ClassLoader loader) {
212        final TranslationRegistry registry = TranslationRegistry.create(Key.key("permissionsex", bundleName.toLowerCase(Locale.ROOT)));
213        registry.defaultLocale(DEFAULT_LOCALE);
214
215        languages.forEach(language  -> {
216            final ResourceBundle bundle = ResourceBundle.getBundle(
217                bundleName,
218                language,
219                loader,
220                UTF8ResourceBundleControl.get()
221            );
222
223            for (final String key : bundle.keySet()) {
224                try {
225                    registry.register(
226                        formatKey(bundleName, key),
227                        language,
228                        new MessageFormat(SINGLE_QUOTE_PATTERN.matcher(bundle.getString(key)).replaceAll("''"))
229                    );
230                } catch (final Exception ex) {
231                    LOGGER.warn("Failed to register translation key {} in bundle {}", key, bundleName, ex);
232                }
233            }
234        });
235
236        GlobalTranslator.get().addSource(registry);
237    }
238
239    private static String formatKey(final String bundleName, final String key) {
240        return bundleName + '/' + key;
241    }
242}