diff --git a/Core/src/main/java/com/songoda/core/locale/LocaleFileManager.java b/Core/src/main/java/com/songoda/core/locale/LocaleFileManager.java new file mode 100644 index 00000000..f39368c7 --- /dev/null +++ b/Core/src/main/java/com/songoda/core/locale/LocaleFileManager.java @@ -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 downloadMissingTranslations(File targetDirectory) throws IOException { + List availableLanguages = this.fetchAvailableLanguageFiles(); + if (availableLanguages == null) { + return Collections.emptyList(); + } + + Files.createDirectories(targetDirectory.toPath()); + + List 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 fetchAvailableLanguageFiles() throws IOException { + String projectLanguageIndex = fetchProjectFile("_index.txt"); + + if (projectLanguageIndex == null) { + return null; + } + + List 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); + } +} diff --git a/Core/src/main/java/com/songoda/core/locale/LocaleManager.java b/Core/src/main/java/com/songoda/core/locale/LocaleManager.java new file mode 100644 index 00000000..82b874a6 --- /dev/null +++ b/Core/src/main/java/com/songoda/core/locale/LocaleManager.java @@ -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 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 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 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; + } +} diff --git a/Core/src/test/java/com/songoda/core/locale/LocaleFileManagerTest.java b/Core/src/test/java/com/songoda/core/locale/LocaleFileManagerTest.java new file mode 100644 index 00000000..a5a503b7 --- /dev/null +++ b/Core/src/test/java/com/songoda/core/locale/LocaleFileManagerTest.java @@ -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 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 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 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 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 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); + } +}