Provisional first implementation of the new localization system

It it not done yet. A lot of usability features are still missing.
Including a proper interface to interact with the whole
new system in the plugins.
This commit is contained in:
Christian Koop 2022-09-29 21:55:57 +02:00
parent 2e15ed5d28
commit b168ad0738
No known key found for this signature in database
GPG Key ID: 89A8181384E010A3
3 changed files with 357 additions and 0 deletions

View File

@ -0,0 +1,101 @@
package com.songoda.core.locale;
import com.songoda.core.http.HttpClient;
import com.songoda.core.http.HttpResponse;
import com.songoda.core.http.UnexpectedHttpStatusException;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
public class LocaleFileManager {
private final HttpClient httpClient;
private final String projectName;
public LocaleFileManager(HttpClient httpClient, String projectName) {
this.httpClient = httpClient;
this.projectName = projectName;
}
public List<String> downloadMissingTranslations(File targetDirectory) throws IOException {
List<String> availableLanguages = this.fetchAvailableLanguageFiles();
if (availableLanguages == null) {
return Collections.emptyList();
}
Files.createDirectories(targetDirectory.toPath());
List<String> downloadedLocales = new LinkedList<>();
for (String languageFileName : availableLanguages) {
File languageFile = new File(targetDirectory, languageFileName);
if (languageFile.exists()) {
continue;
}
String languageFileContents = fetchProjectFile(languageFileName);
if (languageFileContents == null) {
throw new IOException("Failed to download language file " + languageFileName); // TODO: Better exception
}
try (Writer writer = new FileWriter(languageFile)) {
writer.write(languageFileContents);
}
downloadedLocales.add(languageFileName);
}
return downloadedLocales;
}
public @Nullable List<String> fetchAvailableLanguageFiles() throws IOException {
String projectLanguageIndex = fetchProjectFile("_index.txt");
if (projectLanguageIndex == null) {
return null;
}
List<String> result = new LinkedList<>();
for (String line : projectLanguageIndex.split("\r?\n")) {
line = line.trim();
if (!line.startsWith("#") && !line.isEmpty()) {
result.add(line);
}
}
return result;
}
public String fetchProjectFile(String fileName) throws IOException {
String url = formatUrl("https://songoda.github.io/Translations/projects/%s/%s", this.projectName, fileName);
HttpResponse httpResponse = this.httpClient.get(url);
if (httpResponse.getResponseCode() == 404) {
return null;
}
if (httpResponse.getResponseCode() != 200) {
throw new UnexpectedHttpStatusException(httpResponse.getResponseCode(), url);
}
return httpResponse.getBodyAsString();
}
private static String formatUrl(String url, Object... params) throws UnsupportedEncodingException {
Object[] encodedParams = new Object[params.length];
for (int i = 0; i < params.length; i++) {
encodedParams[i] = URLEncoder.encode(params[i].toString(), "UTF-8");
}
return String.format(url, encodedParams);
}
}

View File

@ -0,0 +1,115 @@
package com.songoda.core.locale;
import com.songoda.core.configuration.songoda.SongodaYamlConfig;
import com.songoda.core.configuration.yaml.YamlConfiguration;
import com.songoda.core.http.SimpleHttpClient;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.Nullable;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
public class LocaleManager {
protected final Plugin plugin;
protected final File localesDirectory;
protected final List<SongodaYamlConfig> loadedLocales = new LinkedList<>();
protected final @Nullable YamlConfiguration fallbackLocale;
public LocaleManager(Plugin plugin) throws IOException {
this.plugin = plugin;
this.localesDirectory = new File(this.plugin.getDataFolder(), "locales");
this.fallbackLocale = loadFallbackLocale();
}
public List<String> downloadMissingLocales() {
LocaleFileManager localeFileManager = new LocaleFileManager(new SimpleHttpClient(), this.plugin.getName());
try {
return localeFileManager.downloadMissingTranslations(this.localesDirectory);
} catch (IOException ex) {
this.plugin.getLogger().warning("Failed to download missing locales: " + ex.getMessage());
}
return Collections.emptyList();
}
public void load(String locale) throws IOException {
File fileToLoad = determineAvailableLocaleVariation(locale);
if (fileToLoad == null) {
throw new FileNotFoundException("Locale file " + locale + " not found");
}
for (SongodaYamlConfig loadedLocale : this.loadedLocales) {
if (loadedLocale.file.equals(fileToLoad)) {
return;
}
}
SongodaYamlConfig localeConfig = new SongodaYamlConfig(fileToLoad);
localeConfig.load();
this.loadedLocales.add(localeConfig);
}
public void loadExclusively(String locale) {
loadExclusively(Collections.singletonList(locale));
}
public void loadExclusively(List<String> locales) {
unloadAll();
}
public void unloadAll() {
this.loadedLocales.clear();
}
protected @Nullable File determineAvailableLocaleVariation(String locale) {
File localeFile = new File(this.localesDirectory, locale + ".lang");
if (localeFile.exists()) {
return localeFile;
}
File[] availableLocales = this.localesDirectory.listFiles();
if (availableLocales == null) {
return null;
}
for (File availableLocale : availableLocales) {
if (availableLocale.getName().startsWith(locale)) {
return availableLocale;
}
}
return null;
}
protected @Nullable YamlConfiguration loadFallbackLocale() throws IOException {
URL fallbackLocaleUrl = this.plugin.getClass().getResource("/en_US.lang");
if (fallbackLocaleUrl == null) {
return null;
}
YamlConfiguration locale = new YamlConfiguration();
try (Reader reader = new InputStreamReader(fallbackLocaleUrl.openStream(), StandardCharsets.UTF_8)) {
locale.load(reader);
}
return locale;
}
protected SongodaYamlConfig parseLocaleFile(File file) throws IOException {
SongodaYamlConfig locale = new SongodaYamlConfig(file, this.plugin.getLogger());
locale.load();
return locale;
}
}

View File

@ -0,0 +1,141 @@
package com.songoda.core.locale;
import be.seeseemelk.mockbukkit.MockBukkit;
import be.seeseemelk.mockbukkit.MockPlugin;
import com.songoda.core.http.MockHttpClient;
import com.songoda.core.http.MockHttpResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
class LocaleFileManagerTest {
private final byte[] validIndexFile = ("# This is a comment\n\nen_US.lang\nen.yml\nde.txt\n").getBytes(StandardCharsets.UTF_8);
@AfterEach
void tearDown() {
MockBukkit.unmock();
}
@Test
void downloadMissingTranslations_EmptyTargetDir() throws IOException {
MockBukkit.mock();
MockPlugin plugin = MockBukkit.createMockPlugin();
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(200, this.validIndexFile));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "test");
List<String> downloadedFiles = localeFileManager.downloadMissingTranslations(plugin.getDataFolder());
Assertions.assertSame(3, downloadedFiles.size());
Assertions.assertEquals("en.yml", downloadedFiles.get(1));
Assertions.assertEquals("en_US.lang", downloadedFiles.get(0));
Assertions.assertEquals("de.txt", downloadedFiles.get(2));
String[] localeFiles = plugin.getDataFolder().list();
Assertions.assertNotNull(localeFiles);
Assertions.assertArrayEquals(new String[] {"en.yml", "en_US.lang", "de.txt"}, localeFiles);
Assertions.assertSame(4, httpClient.callsOnGet.size());
Assertions.assertTrue(httpClient.callsOnGet.get(0).contains("/test/_index.txt"));
Assertions.assertTrue(httpClient.callsOnGet.get(1).contains("/test/en_US.lang"));
Assertions.assertTrue(httpClient.callsOnGet.get(2).contains("/test/en.yml"));
Assertions.assertTrue(httpClient.callsOnGet.get(3).contains("/test/de.txt"));
}
@Test
void downloadMissingTranslations() throws IOException {
MockBukkit.mock();
MockPlugin plugin = MockBukkit.createMockPlugin();
Files.createDirectories(plugin.getDataFolder().toPath());
Files.createFile(new File(plugin.getDataFolder(), "en_US.lang").toPath());
Files.createFile(new File(plugin.getDataFolder(), "fr.lang").toPath());
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(200, this.validIndexFile));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "test");
List<String> downloadedFiles = localeFileManager.downloadMissingTranslations(plugin.getDataFolder());
Assertions.assertSame(2, downloadedFiles.size());
Assertions.assertEquals("en.yml", downloadedFiles.get(0));
Assertions.assertEquals("de.txt", downloadedFiles.get(1));
String[] localeFiles = plugin.getDataFolder().list();
Assertions.assertNotNull(localeFiles);
Assertions.assertArrayEquals(new String[] {"en.yml", "en_US.lang", "fr.lang", "de.txt"}, localeFiles);
Assertions.assertSame(3, httpClient.callsOnGet.size());
Assertions.assertTrue(httpClient.callsOnGet.get(0).contains("/test/_index.txt"));
Assertions.assertTrue(httpClient.callsOnGet.get(1).contains("/test/en.yml"));
Assertions.assertTrue(httpClient.callsOnGet.get(2).contains("/test/de.txt"));
}
@Test
void fetchAvailableLanguageFiles() throws IOException {
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(200, this.validIndexFile));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "test");
List<String> availableLanguages = localeFileManager.fetchAvailableLanguageFiles();
Assertions.assertSame(1, httpClient.callsOnGet.size());
Assertions.assertTrue(httpClient.callsOnGet.get(0).contains("/test/"));
Assertions.assertNotNull(availableLanguages);
Assertions.assertSame(3, availableLanguages.size());
Assertions.assertTrue(availableLanguages.contains("en_US.lang"));
Assertions.assertTrue(availableLanguages.contains("en.yml"));
Assertions.assertTrue(availableLanguages.contains("de.txt"));
}
@Test
void fetchAvailableLanguageFiles_SpecialCharsInProjectName() throws IOException {
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(200, this.validIndexFile));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "test project (special)");
List<String> availableLanguages = localeFileManager.fetchAvailableLanguageFiles();
Assertions.assertSame(1, httpClient.callsOnGet.size());
Assertions.assertTrue(httpClient.callsOnGet.get(0).contains("/test+project+%28special%29/"));
Assertions.assertNotNull(availableLanguages);
Assertions.assertSame(3, availableLanguages.size());
Assertions.assertTrue(availableLanguages.contains("en_US.lang"));
Assertions.assertTrue(availableLanguages.contains("en.yml"));
Assertions.assertTrue(availableLanguages.contains("de.txt"));
}
@Test
void fetchAvailableLanguageFiles_EmptyIndex() throws IOException {
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(200, new byte[0]));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "empty-project");
List<String> availableLanguages = localeFileManager.fetchAvailableLanguageFiles();
Assertions.assertNotNull(availableLanguages);
Assertions.assertTrue(availableLanguages.isEmpty());
}
@Test
void fetchAvailableLanguageFiles_UnknownProject() throws IOException {
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(404, new byte[0]));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "unknown");
Assertions.assertNull(localeFileManager.fetchAvailableLanguageFiles());
}
@Test
void fetchAvailableLanguageFiles_HttpStatus500() {
MockHttpClient httpClient = new MockHttpClient(new MockHttpResponse(500, new byte[0]));
LocaleFileManager localeFileManager = new LocaleFileManager(httpClient, "test");
Assertions.assertThrows(IOException.class, localeFileManager::fetchAvailableLanguageFiles);
}
}