
com.lokalized.LocalizedStringLoader Maven / Gradle / Ivy
Show all versions of lokalized-java Show documentation
/*
* Copyright 2017 Product Mog LLC.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.lokalized;
import com.lokalized.LocalizedString.LanguageFormTranslation;
import com.lokalized.LocalizedString.LanguageFormTranslationRange;
import com.lokalized.MinimalJson.Json;
import com.lokalized.MinimalJson.JsonArray;
import com.lokalized.MinimalJson.JsonObject;
import com.lokalized.MinimalJson.JsonObject.Member;
import com.lokalized.MinimalJson.JsonValue;
import javax.annotation.Nonnull;
import javax.annotation.concurrent.ThreadSafe;
import java.io.File;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.logging.Logger;
import java.util.stream.Collectors;
import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
/**
* Utility methods for loading localized strings files.
*
* @author Mark Allen
*/
@ThreadSafe
public final class LocalizedStringLoader {
@Nonnull
private static final Set SUPPORTED_LANGUAGE_TAGS;
@Nonnull
private static final Map SUPPORTED_LANGUAGE_FORMS_BY_NAME;
@Nonnull
private static final Logger LOGGER;
static {
LOGGER = Logger.getLogger(LoggerType.LOCALIZED_STRING_LOADER.getLoggerName());
SUPPORTED_LANGUAGE_TAGS = Collections.unmodifiableSet(Arrays.stream(Locale.getAvailableLocales())
.map(locale -> locale.toLanguageTag())
.collect(Collectors.toSet()));
Set supportedLanguageForms = new LinkedHashSet<>();
supportedLanguageForms.addAll(Arrays.asList(Gender.values()));
supportedLanguageForms.addAll(Arrays.asList(Cardinality.values()));
supportedLanguageForms.addAll(Arrays.asList(Ordinality.values()));
Map supportedLanguageFormsByName = new LinkedHashMap<>();
for (LanguageForm languageForm : supportedLanguageForms) {
if (!languageForm.getClass().isEnum())
throw new IllegalArgumentException(format("The %s interface must be implemented by enum types. %s is not an enum",
LanguageForm.class.getSimpleName(), languageForm.getClass().getSimpleName()));
String languageFormName = ((Enum>) languageForm).name();
LanguageForm existingLanguageForm = supportedLanguageFormsByName.get(languageFormName);
if (existingLanguageForm != null)
throw new IllegalArgumentException(format("There is already a language form %s.%s whose name collides with %s.%s. " +
"Language form names must be unique", existingLanguageForm.getClass().getSimpleName(), languageFormName,
languageForm.getClass().getSimpleName(), languageFormName));
// Massage Cardinality to match file format, e.g. "ONE" -> "CARDINALITY_ONE"
if (languageForm instanceof Cardinality)
languageFormName = LocalizedStringUtils.localizedStringNameForCardinalityName(languageFormName);
// Massage Ordinality to match file format, e.g. "ONE" -> "ORDINALITY_ONE"
if (languageForm instanceof Ordinality)
languageFormName = LocalizedStringUtils.localizedStringNameForOrdinalityName(languageFormName);
supportedLanguageFormsByName.put(languageFormName, languageForm);
}
SUPPORTED_LANGUAGE_FORMS_BY_NAME = Collections.unmodifiableMap(supportedLanguageFormsByName);
}
private LocalizedStringLoader() {
// Non-instantiable
}
/**
* Loads all localized string files present in the specified package on the classpath.
*
* Filenames must correspond to the IETF BCP 47 language tag format.
*
* Example filenames:
*
* - {@code en}
* - {@code es-MX}
* - {@code nan-Hant-TW}
*
*
* Like any classpath reference, packages are separated using the {@code /} character.
*
* Example package names:
*
* - {@code strings}
*
- {@code com/lokalized/strings}
*
*
* Note: this implementation only scans the specified package, it does not descend into child packages.
*
* @param classpathPackage location of a package on the classpath, not null
* @return per-locale sets of localized strings, not null
* @throws LocalizedStringLoadingException if an error occurs while loading localized string files
*/
@Nonnull
public static Map> loadFromClasspath(@Nonnull String classpathPackage) {
requireNonNull(classpathPackage);
ClassLoader classLoader = LocalizedStringLoader.class.getClassLoader();
URL url = classLoader.getResource(classpathPackage);
if (url == null)
throw new LocalizedStringLoadingException(format("Unable to find package '%s' on the classpath", classpathPackage));
return loadFromDirectory(new File(url.getFile()));
}
/**
* Loads all localized string files present in the specified directory.
*
* Filenames must correspond to the IETF BCP 47 language tag format.
*
* Example filenames:
*
* - {@code en}
* - {@code es-MX}
* - {@code nan-Hant-TW}
*
*
* Note: this implementation only scans the specified directory, it does not descend into child directories.
*
* @param directory directory in which to search for localized string files, not null
* @return per-locale sets of localized strings, not null
* @throws LocalizedStringLoadingException if an error occurs while loading localized string files
*/
@Nonnull
public static Map> loadFromFilesystem(@Nonnull Path directory) {
requireNonNull(directory);
return loadFromDirectory(directory.toFile());
}
// TODO: should we expose methods for loading a single file?
/**
* Loads all localized string files present in the specified directory.
*
* @param directory directory in which to search for localized string files, not null
* @return per-locale sets of localized strings, not null
* @throws LocalizedStringLoadingException if an error occurs while loading localized string files
*/
@Nonnull
private static Map> loadFromDirectory(@Nonnull File directory) {
requireNonNull(directory);
if (!directory.exists())
throw new LocalizedStringLoadingException(format("Location '%s' does not exist",
directory));
if (!directory.isDirectory())
throw new LocalizedStringLoadingException(format("Location '%s' exists but is not a directory",
directory));
Map> localizedStringsByLocale =
new TreeMap<>((locale1, locale2) -> locale1.toLanguageTag().compareTo(locale2.toLanguageTag()));
File[] files = directory.listFiles();
if (files != null) {
for (File file : files) {
String languageTag = file.getName();
if (SUPPORTED_LANGUAGE_TAGS.contains(languageTag)) {
LOGGER.fine(format("Loading localized strings file '%s'...", languageTag));
Locale locale = Locale.forLanguageTag(file.getName());
localizedStringsByLocale.put(locale, parseLocalizedStringsFile(file));
} else {
LOGGER.fine(format("File '%s' does not correspond to a known language tag, skipping...", languageTag));
}
}
}
return Collections.unmodifiableMap(localizedStringsByLocale);
}
/**
* Parses out a set of localized strings from the given file.
*
* @param file the file to parse, not null
* @return the set of localized strings contained in the file, not null
* @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
*/
@Nonnull
private static Set parseLocalizedStringsFile(@Nonnull File file) {
requireNonNull(file);
String canonicalPath;
try {
canonicalPath = file.getCanonicalPath();
} catch (IOException e) {
throw new LocalizedStringLoadingException(
format("Unable to determine canonical path for localized strings file %s", file), e);
}
if (!Files.isRegularFile(file.toPath()))
throw new LocalizedStringLoadingException(format("%s is not a regular file", canonicalPath));
String localizedStringsFileContents;
try {
localizedStringsFileContents = new String(Files.readAllBytes(file.toPath()), UTF_8).trim();
} catch (IOException e) {
throw new LocalizedStringLoadingException(format("Unable to load localized strings file contents for %s",
canonicalPath), e);
}
if ("".equals(localizedStringsFileContents))
return Collections.emptySet();
Set localizedStrings = new HashSet<>();
JsonValue outerJsonValue = Json.parse(localizedStringsFileContents);
if (!outerJsonValue.isObject())
throw new LocalizedStringLoadingException(format("%s: a localized strings file must be comprised of a single JSON object", canonicalPath));
JsonObject outerJsonObject = outerJsonValue.asObject();
for (Member member : outerJsonObject) {
String key = member.getName();
JsonValue value = member.getValue();
localizedStrings.add(parseLocalizedString(canonicalPath, key, value));
}
return Collections.unmodifiableSet(localizedStrings);
}
/**
* Parses "toplevel" localized string data.
*
* Operates recursively if alternatives are encountered.
*
* @param canonicalPath the unique path to the file (or URL) being parsed, used for error reporting. not null
* @param key the toplevel translation key, not null
* @param jsonValue the toplevel translation value - might be a simple string, might be a complex object. not null
* @return a localized string instance, not null
* @throws LocalizedStringLoadingException if an error occurs while parsing the localized string file
*/
@Nonnull
private static LocalizedString parseLocalizedString(@Nonnull String canonicalPath, @Nonnull String key, @Nonnull JsonValue jsonValue) {
requireNonNull(canonicalPath);
requireNonNull(key);
requireNonNull(jsonValue);
if (jsonValue.isString()) {
// Simple case - just a key and a value, no translation rules
//
// Example format:
//
// {
// "Hello, world!" : "Приветствую, мир"
// }
String translation = jsonValue.asString();
if (translation == null)
throw new LocalizedStringLoadingException(format("%s: a translation is required for key '%s'", canonicalPath, key));
return new LocalizedString.Builder(key).translation(translation).build();
} else if (jsonValue.isObject()) {
// More complex case, there can be placeholders and alternatives.
//
// Example format:
//
// {
// "I read {{bookCount}} books" : {
// "translation" : "I read {{bookCount}} {{books}}",
// "commentary" : "Message shown when user achieves her book-reading goal for the month",
// "placeholders" : {
// "books" : {
// "value" : "bookCount",
// "translations" : {
// "ONE" : "book",
// "OTHER" : "books"
// }
// }
// },
// "alternatives" : [
// {
// "bookCount == 0" : {
// "translation" : "I haven't read any books"
// }
// }
// ]
// }
// }
JsonObject localizedStringObject = jsonValue.asObject();
String translation = null;
JsonValue translationJsonValue = localizedStringObject.get("translation");
if (translationJsonValue != null && !translationJsonValue.isNull()) {
if (!translationJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: translation must be a string for key '%s'", canonicalPath, key));
translation = translationJsonValue.asString();
}
String commentary = null;
JsonValue commentaryJsonValue = localizedStringObject.get("commentary");
if (commentaryJsonValue != null && !commentaryJsonValue.isNull()) {
if (!commentaryJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: commentary must be a string for key '%s'", canonicalPath, key));
commentary = commentaryJsonValue.asString();
}
Map languageFormTranslationsByPlaceholder = new LinkedHashMap<>();
JsonValue placeholdersJsonValue = localizedStringObject.get("placeholders");
if (placeholdersJsonValue != null && !placeholdersJsonValue.isNull()) {
if (!placeholdersJsonValue.isObject())
throw new LocalizedStringLoadingException(format("%s: the placeholders value must be an object. Key is '%s'", canonicalPath, key));
JsonObject placeholdersJsonObject = placeholdersJsonValue.asObject();
for (Member placeholderMember : placeholdersJsonObject) {
String placeholderKey = placeholderMember.getName();
JsonValue placeholderJsonValue = placeholderMember.getValue();
String value = null;
LanguageFormTranslationRange rangeValue = null;
if (!placeholderJsonValue.isObject())
throw new LocalizedStringLoadingException(format("%s: the placeholder value must be an object. Key is '%s'", canonicalPath, key));
JsonObject placeholderJsonObject = placeholderJsonValue.asObject();
JsonValue valueJsonValue = placeholderJsonObject.get("value");
JsonValue rangeJsonValue = placeholderJsonObject.get("range");
boolean hasValue = valueJsonValue != null && !valueJsonValue.isNull();
boolean hasRangeValue = rangeJsonValue != null && !rangeJsonValue.isNull();
if (!hasValue && !hasRangeValue)
throw new LocalizedStringLoadingException(format("%s: a placeholder translation value or range is required. Key is '%s'", canonicalPath, key));
if (hasValue && hasRangeValue)
throw new LocalizedStringLoadingException(format("%s: a placeholder translation cannot have both a value and a range. Key is '%s'", canonicalPath, key));
if (hasRangeValue) {
if (!rangeJsonValue.isObject())
throw new LocalizedStringLoadingException(format("%s: the placeholder translation range must be an object. Key is '%s'", canonicalPath, key));
JsonObject rangeJsonObject = rangeJsonValue.asObject();
JsonValue rangeValueStartJsonValue = rangeJsonObject.get("start");
JsonValue rangeValueEndJsonValue = rangeJsonObject.get("end");
if (rangeValueStartJsonValue == null || rangeValueStartJsonValue.isNull())
throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start is required. Key is '%s'", canonicalPath, key));
if (rangeValueEndJsonValue == null || rangeValueEndJsonValue.isNull())
throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end is required. Key is '%s'", canonicalPath, key));
if (!rangeValueStartJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: a placeholder translation range start must be a string. Key is '%s'", canonicalPath, key));
if (!rangeValueEndJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: a placeholder translation range end must be a string. Key is '%s'", canonicalPath, key));
rangeValue = new LanguageFormTranslationRange(rangeValueStartJsonValue.asString(), rangeValueEndJsonValue.asString());
} else {
if (!valueJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: a placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
value = valueJsonValue.asString();
}
JsonValue translationsJsonValue = placeholderJsonObject.get("translations");
if (translationsJsonValue == null || translationsJsonValue.isNull())
continue;
if (!translationsJsonValue.isObject())
throw new LocalizedStringLoadingException(format("%s: the placeholder translations value must be an object. Key is '%s'", canonicalPath, key));
Map translationsByLanguageForm = new LinkedHashMap<>();
JsonObject translationsJsonObject = translationsJsonValue.asObject();
for (Member translationMember : translationsJsonObject) {
String languageFormTranslationKey = translationMember.getName();
JsonValue languageFormTranslationJsonValue = translationMember.getValue();
LanguageForm languageForm = SUPPORTED_LANGUAGE_FORMS_BY_NAME.get(languageFormTranslationKey);
if (languageForm == null)
throw new LocalizedStringLoadingException(format("%s: unexpected placeholder translation language form encountered. Key is '%s'. " +
"You provided '%s', valid values are [%s]", canonicalPath, key, languageFormTranslationKey,
SUPPORTED_LANGUAGE_FORMS_BY_NAME.keySet().stream().collect(Collectors.joining(", "))));
if (!languageFormTranslationJsonValue.isString())
throw new LocalizedStringLoadingException(format("%s: the placeholder translation value must be a string. Key is '%s'", canonicalPath, key));
translationsByLanguageForm.put(languageForm, languageFormTranslationJsonValue.asString());
}
LanguageFormTranslation languageFormTranslation = rangeValue != null
? new LanguageFormTranslation(rangeValue, translationsByLanguageForm)
: new LanguageFormTranslation(value, translationsByLanguageForm);
languageFormTranslationsByPlaceholder.put(placeholderKey, languageFormTranslation);
}
}
List alternatives = new ArrayList<>();
JsonValue alternativesJsonValue = localizedStringObject.get("alternatives");
if (alternativesJsonValue != null && !alternativesJsonValue.isNull()) {
if (!alternativesJsonValue.isArray())
throw new LocalizedStringLoadingException(format("%s: alternatives must be an array. Key is '%s'", canonicalPath, key));
JsonArray alternativesJsonArray = alternativesJsonValue.asArray();
for (JsonValue alternativeJsonValue : alternativesJsonArray) {
if (alternativeJsonValue == null || alternativeJsonValue.isNull())
continue;
JsonObject outerJsonObject = alternativeJsonValue.asObject();
if (!outerJsonObject.isObject())
throw new LocalizedStringLoadingException(format("%s: alternative value must be an object. Key is '%s'", canonicalPath, key));
for (Member member : outerJsonObject) {
String alternativeKey = member.getName();
JsonValue alternativeValue = member.getValue();
alternatives.add(parseLocalizedString(canonicalPath, alternativeKey, alternativeValue));
}
}
}
return new LocalizedString.Builder(key)
.translation(translation)
.commentary(commentary)
.languageFormTranslationsByPlaceholder(languageFormTranslationsByPlaceholder)
.alternatives(alternatives)
.build();
} else {
throw new LocalizedStringLoadingException(format("%s: either a translation string or object value is required for key '%s'",
canonicalPath, key));
}
}
}