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}