Skip to content

Commit

Permalink
feat: nested translation keys
Browse files Browse the repository at this point in the history
  • Loading branch information
dunkyl committed Dec 22, 2023
1 parent f6a59e7 commit cd7a1e7
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 113 deletions.
43 changes: 8 additions & 35 deletions src/main/java/dev/qixils/quasicord/Quasicord.java
Original file line number Diff line number Diff line change
Expand Up @@ -60,18 +60,15 @@ public class Quasicord {
/**
*
* @param namespace
* @param locales The list of supported locales, with the first locale being treated as the default.
* @param defaultLocale The default locale
* @param configRoot
* @param activity
* @param eventHandler
* @throws LoginException
* @throws InterruptedException
* @throws IOException
*/
public Quasicord(@NonNull String namespace, @NonNull List<Locale> locales, @NonNull Path configRoot, @Nullable Activity activity, @Nullable Object eventHandler) throws LoginException, InterruptedException, IOException {
if (locales.isEmpty()) {
throw new IllegalArgumentException("'locales' parameter must have at least one object");
}
public Quasicord(@NonNull String namespace, @NonNull Locale defaultLocale, @NonNull Path configRoot, @Nullable Activity activity, @Nullable Object eventHandler) throws LoginException, InterruptedException, IOException {

// misc initialization
this.namespace = namespace;
Expand All @@ -80,9 +77,9 @@ public Quasicord(@NonNull String namespace, @NonNull List<Locale> locales, @NonN
eventDispatcher.registerListeners(eventHandler);

// register translation providers
translationProvider = new TranslationProvider(namespace, locales);
translationProvider = new TranslationProvider(namespace, defaultLocale);
TranslationProvider.registerInstance(translationProvider);
TranslationProvider.registerInstance(new TranslationProvider(Key.LIBRARY_NAMESPACE, Locale.ENGLISH, locales));
TranslationProvider.registerInstance(new TranslationProvider(Key.LIBRARY_NAMESPACE, Locale.ENGLISH));

// load configuration
YamlConfigurationLoader loader = YamlConfigurationLoader.builder()
Expand All @@ -94,7 +91,7 @@ public Quasicord(@NonNull String namespace, @NonNull List<Locale> locales, @NonN

// load database and locale provider
database = new DatabaseManager(namespace, config.environment());
localeProvider = new LocaleProvider(locales.get(0), database);
localeProvider = new LocaleProvider(defaultLocale, database);
LocaleProvider.setInstance(localeProvider);

// initialize JDA and relevant data
Expand Down Expand Up @@ -288,7 +285,7 @@ public void shutdownNow() {
*/
public static class Builder {
protected @Nullable String namespace;
protected @NonNull List<Locale> locales = new ArrayList<>();
protected @NonNull Locale locale = Locale.ENGLISH;
protected @NonNull Path configRoot = Paths.get(".").toAbsolutePath();
protected @Nullable Activity activity;
protected @Nullable Object eventHandler;
Expand Down Expand Up @@ -320,30 +317,7 @@ public Builder namespace(@NonNull String namespace) {
*/
@Contract("_ -> this")
public Builder defaultLocale(@NonNull Locale locale) {
locales.add(0, locale);
return this;
}

/**
* Sets the locales for the bot. The first element is treated as the default.
*
* @param locales the locales to set
* @return this builder
*/
@Contract("_ -> this")
public Builder setLocales(@NonNull Locale... locales) {
this.locales.clear();
return addLocales(locales);
}

/**
* Adds locales to the bot.
*
* @param locales the locales to add
*/
@Contract("_ -> this")
public Builder addLocales(@NonNull Locale... locales) {
this.locales.addAll(Arrays.asList(locales));
this.locale = locale;
return this;
}

Expand Down Expand Up @@ -396,8 +370,7 @@ public Builder eventHandler(@Nullable Object eventHandler) {
public @NonNull Quasicord build() throws IllegalStateException, LoginException, InterruptedException, IOException {
if (namespace == null)
throw new IllegalStateException("namespace must be set");
List<Locale> locales = this.locales.isEmpty() ? Collections.singletonList(Locale.ENGLISH) : this.locales;
return new Quasicord(namespace, locales, configRoot, activity, eventHandler);
return new Quasicord(namespace, locale, configRoot, activity, eventHandler);
}
}
}
155 changes: 78 additions & 77 deletions src/main/java/dev/qixils/quasicord/locale/TranslationProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,15 @@
import net.dv8tion.jda.api.interactions.commands.localization.LocalizationFunction;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml;

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.*;
import java.util.jar.JarFile;
import java.util.regex.Pattern;

/**
* Provides the translation(s) for a key inside the configured namespace.
Expand All @@ -35,8 +36,8 @@ public final class TranslationProvider {
private static final @NonNull Logger logger = LoggerFactory.getLogger(TranslationProvider.class);
private final @NonNull String namespace;
private final @NonNull Locale defaultLocale;
private final @NonNull Set<Locale> locales;
private final @NonNull Map<String, Map<Locale, ?>> allTranslations = new HashMap<>();
private final @NonNull Set<Locale> locales = new HashSet<>();
private final @NonNull Map<String, Map<Locale, Object>> allTranslations = new HashMap<>();
private final @NonNull Map<String, Map<DiscordLocale, String>> discordTranslations = new HashMap<>();

/**
Expand All @@ -52,32 +53,13 @@ public final class TranslationProvider {
*
* @param namespace the directory in which the translations are stored
* @param defaultLocale the default locale to use if no translation is found for the current locale
* @param supportedLocales the locales to load
*/
public TranslationProvider(@NonNull String namespace, @NonNull Locale defaultLocale, @NonNull Collection<Locale> supportedLocales) throws IOException {
public TranslationProvider(@NonNull String namespace, @NonNull Locale defaultLocale) throws IOException {
this.namespace = namespace.toLowerCase(Locale.ROOT);
this.defaultLocale = defaultLocale;
this.locales = new HashSet<>(supportedLocales);
loadTranslations();
}

/**
* Creates a new translation provider for the given resource source and default locale.
* <p>
* Your bot or plugin should store its language files inside the directory
* {@code src/main/resources/langs/<namespace>}, where {@code <namespace>} is the
* same as the string you pass in to the {@code namespace} parameter.
* </p>
* <b>Note:</b> The {@code namespace} parameter is converted to lowercase. Usage of
* non-alphanumeric characters is discouraged, though not explicitly forbidden.
*
* @param namespace the directory in which the translations are stored
* @param supportedLocales the locales to load, with the first element being treated as the default locale
*/
public TranslationProvider(@NonNull String namespace, @NonNull List<Locale> supportedLocales) throws IOException {
this(namespace, supportedLocales.get(0), supportedLocales);
}

/**
* Returns a view of the supported locales.
*
Expand Down Expand Up @@ -129,65 +111,84 @@ public Set<Locale> getLocales() {
return DiscordLocale.UNKNOWN;
}

private static final Set<String> PLURAL_KEYS = new HashSet<>(List.of("zero", "one", "two", "few", "many", "other"));

// Gets all the translations contained in a (maybe nested) yaml value
@SuppressWarnings("unchecked")
private HashMap<String, Object> flattenKeys(String prefix, Map<String, ?> keys) {
var results = new HashMap<String, Object>();
for (var entry : keys.entrySet()) {
var key = prefix + entry.getKey();
switch (entry.getValue()) {
case String single ->
results.put(key, single);
case Map<?, ?> map when PLURAL_KEYS.containsAll((Set<String>)map.keySet()) ->
results.put(key, map);
case Map<?, ?> map ->
results.putAll(flattenKeys(key + ".", (Map<String, ?>) map));
case Object other ->
throw new RuntimeException("Invalid translation value: " + key + ": " + other);
}
}
return results;
}

private static final Pattern LANGUAGE_FILE = Pattern.compile("(?<languageTag>\\w{2})\\.ya?ml");

// Find all the items in the JVM resource path
private List<String> listResourcesIn(String path) throws IOException {
var url = ClassLoader.getSystemResource(path);
if (url.getProtocol().equals("file")) { // OS dir
return Arrays.stream(new File(url.getPath()).list()).toList();
} else { // packed in jar
try (var jar = new JarFile(url.getPath().substring(5, url.getPath().indexOf("!")))) {
return Collections.list(jar.entries()).stream()
.filter(entry -> entry.getName().startsWith(path) )
.map( entry -> entry.getName().substring(path.length()) )
.toList();
}
}
}

/**
* Loads translations from the configured namespace.
*/
@SuppressWarnings("unchecked")
public void loadTranslations() throws IOException {
// reset maps
allTranslations.clear();
discordTranslations.clear();
Yaml yaml = new Yaml();

for (Locale locale : locales) {
String languageTag = locale.toLanguageTag();
DiscordLocale discordLocale = getDiscordLocale(locale);

// load file
try (InputStream inputStream = ClassLoader.getSystemResourceAsStream("langs/" + namespace + '/' + languageTag + ".yaml")) {
if (inputStream == null) {
logger.warn("Failed to load translations for locale {} in namespace {}: file not found", locale, namespace);
continue;
}

Map<String, Object> translationMap = yaml.load(inputStream);
if (translationMap == null) {
logger.warn("Failed to load translations for locale {} in namespace {}: yaml returned null", locale, namespace);
continue;
}

// some language files are nested inside the language code, so we need to extract
// the inner map
if (translationMap.containsKey(languageTag))
translationMap = (Map<String, Object>) translationMap.get(languageTag);

// add translations to maps
for (Map.Entry<String, Object> entry : translationMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// type-checking here feels kinda redundant, but I suppose it's good
// to warn developers of improper i18n files earlier rather than later
if (value instanceof String translation) {
// single translation
Map<Locale, String> translations = (Map<Locale, String>) this.allTranslations.computeIfAbsent(key, k -> new HashMap<>());
translations.put(locale, translation);
if (discordLocale == DiscordLocale.UNKNOWN)
continue;
Map<DiscordLocale, String> discordTranslations = this.discordTranslations.computeIfAbsent(key, k -> new HashMap<>());
discordTranslations.put(discordLocale, translation);
} else if (value instanceof Map) {
// plural translation
Map<Locale, Map<String, String>> translations = (Map<Locale, Map<String, String>>) allTranslations.computeIfAbsent(key, k -> new HashMap<>());
translations.put(locale, (Map<String, String>) value);
// (discord doesn't support plural translations)
} else {
logger.warn("Invalid translation value for key '{}' in {} file {}: {}", key, namespace, languageTag, value);
}
}

// log
logger.info("Loaded {} translations for locale {} in namespace {}", translationMap.size(), locale, namespace);
// each language yaml file
for (var filename : listResourcesIn("langs/" + namespace + "/")) {
var matcher = LANGUAGE_FILE.matcher(filename);
if (!matcher.find()) continue;

// tag from filename (e.g. en)
var languageTag = matcher.group("languageTag");
var locale = Locale.forLanguageTag(languageTag);
locales.add(locale);
var discordLocale = getDiscordLocale(locale);

var file = ClassLoader.getSystemResourceAsStream("langs/" + namespace + "/" + filename);
Map<String, Object> data = yaml.load(file);

// some language files are nested inside the language tag
if (data.containsKey(languageTag))
data = (Map<String, Object>) data.get(languageTag);
// convert to flat keys for dotted string access
data = flattenKeys("", data);

for (var entry : data.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();

allTranslations.computeIfAbsent(key, k -> new HashMap<>()).put(locale, value);

if (discordLocale != DiscordLocale.UNKNOWN // not all locales are Discord-supported
&& value instanceof String single) // only singular translations
discordTranslations.computeIfAbsent(key, k -> new HashMap<>()).put(discordLocale, single);
}

logger.info("Loaded {} translations for locale {} in namespace {}", data.size(), locale, namespace);
}
}

Expand Down Expand Up @@ -362,8 +363,8 @@ public void loadTranslations() throws IOException {
* @param key the translation key
* @return the translation map
*/
@NotNull
public Map<DiscordLocale, String> getDiscordTranslations(@NotNull String key) {
@NonNull
public Map<DiscordLocale, String> getDiscordTranslations(@NonNull String key) {
return discordTranslations.getOrDefault(key, Collections.emptyMap());
}

Expand Down
2 changes: 1 addition & 1 deletion src/test/java/dev/qixils/quasicord/test/MockBot.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

public class MockBot extends Quasicord {
public MockBot() throws LoginException, IOException, InterruptedException {
super("mockbot", Collections.singletonList(Locale.ENGLISH), Paths.get(".").toAbsolutePath(), null, null);
super("mockbot", Locale.ENGLISH, Paths.get(".").toAbsolutePath(), null, null);
}

@Override
Expand Down

0 comments on commit cd7a1e7

Please sign in to comment.