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}