diff --git a/src/main/java/de/extio/game_engine/i18n/Language.java b/src/main/java/de/extio/game_engine/i18n/Language.java new file mode 100644 index 0000000..2eeb551 --- /dev/null +++ b/src/main/java/de/extio/game_engine/i18n/Language.java @@ -0,0 +1,82 @@ +package de.extio.game_engine.i18n; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class Language { + + private String name; + + private String shortName; + + public Language() { + + } + + @JsonCreator + public Language(@JsonProperty("name") final String name, @JsonProperty("shortName") final String shortName) { + this.name = name; + this.shortName = shortName; + } + + public Language(final Language other) { + this.name = other.name; + this.shortName = other.shortName; + } + + public String getName() { + return this.name; + } + + public void setName(final String name) { + this.name = name; + } + + public String getShortName() { + return this.shortName; + } + + public void setShortName(final String shortName) { + this.shortName = shortName; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder(); + builder.append("Language [name="); + builder.append(this.name); + builder.append(", shortName="); + builder.append(this.shortName); + builder.append("]"); + return builder.toString(); + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((this.shortName == null) ? 0 : this.shortName.hashCode()); + return result; + } + + @Override + public boolean equals(final Object obj) { + if (this == obj) { + return true; + } + if ((obj == null) || (this.getClass() != obj.getClass())) { + return false; + } + final Language other = (Language) obj; + if (this.shortName == null) { + if (other.shortName != null) { + return false; + } + } + else if (!this.shortName.equals(other.shortName)) { + return false; + } + return true; + } + +} diff --git a/src/main/java/de/extio/game_engine/i18n/LocalizationAutoConfiguration.java b/src/main/java/de/extio/game_engine/i18n/LocalizationAutoConfiguration.java new file mode 100644 index 0000000..2a4ca54 --- /dev/null +++ b/src/main/java/de/extio/game_engine/i18n/LocalizationAutoConfiguration.java @@ -0,0 +1,36 @@ +package de.extio.game_engine.i18n; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; + +@AutoConfiguration +@ConditionalOnProperty(name = "game-engine.i18n.enabled", havingValue = "true", matchIfMissing = true) +public class LocalizationAutoConfiguration { + + private static final Logger LOGGER = LoggerFactory.getLogger(LocalizationManagerImpl.class); + + @Bean + @ConditionalOnMissingBean + LocalizationManager localizationManager(@Value("${game-engine.i18n.load-on-start:true}") final boolean loadOnStart, @Value("${game-engine.i18n.resource:i18n.yaml}") final String resource) { + final var localizationManager = new LocalizationManagerImpl(); + + if (loadOnStart) { + try (var resourceStream = LocalizationAutoConfiguration.class.getClassLoader().getResourceAsStream(resource)) { + if (resourceStream != null) { + localizationManager.load(resourceStream); + } + } + catch (final Exception exc) { + LOGGER.error("An exception occured while loading localizations from resource: " + resource, exc); + } + } + + return localizationManager; + } + +} diff --git a/src/main/java/de/extio/game_engine/i18n/LocalizationManager.java b/src/main/java/de/extio/game_engine/i18n/LocalizationManager.java new file mode 100644 index 0000000..b7ac972 --- /dev/null +++ b/src/main/java/de/extio/game_engine/i18n/LocalizationManager.java @@ -0,0 +1,36 @@ +package de.extio.game_engine.i18n; + +import java.io.InputStream; +import java.util.List; + +public interface LocalizationManager { + + void resetEntries(); + + void reset(); + + void load(InputStream stream); + + void setLanguage(String lang); + + List getLanguages(); + + String getCurrentLanguage(); + + String translate(Integer id); + + String translate(Integer id, String defaultText); + + String translate(String id); + + String translate(String id, String defaultText); + + void put(String lang, String id, String value); + + void remove(String id); + + Integer getNextId(); + + Localizations getLocalizations(); + +} diff --git a/src/main/java/de/extio/game_engine/i18n/LocalizationManagerImpl.java b/src/main/java/de/extio/game_engine/i18n/LocalizationManagerImpl.java new file mode 100644 index 0000000..95a8098 --- /dev/null +++ b/src/main/java/de/extio/game_engine/i18n/LocalizationManagerImpl.java @@ -0,0 +1,202 @@ +package de.extio.game_engine.i18n; + +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.extio.game_engine.util.ObjectSerialization; + +public class LocalizationManagerImpl implements LocalizationManager { + + private static final Logger LOGGER = LoggerFactory.getLogger(LocalizationManagerImpl.class); + + public final static String NOT_FOUND_PREFIX = "{i18n."; + + private Localizations localizations = new Localizations(); + + private Map currentLanguage; + + private String currentLanguageName; + + private final Map intLookup = new HashMap<>(); + + @Override + public void resetEntries() { + this.localizations.getLanguages().forEach((lang, mapping) -> mapping.clear()); + this.intLookup.clear(); + } + + @Override + public void reset() { + this.localizations = new Localizations(); + this.currentLanguage = null; + this.currentLanguageName = null; + this.intLookup.clear(); + } + + @Override + public void load(final InputStream stream) { + Localizations localizations; + try { + localizations = ObjectSerialization.deserialize(Localizations.class, stream, false, false, null, null, null); + } + catch (final Exception exc) { + LOGGER.error("An exception occured while loading localizations", exc); + return; + } + + if (localizations != null) { + localizations.getLanguages().forEach((lang, newMapping) -> { + final Map localizationEntries = this.localizations.getLanguages().computeIfAbsent(lang, k -> new LinkedHashMap<>()); + for (final Entry newEntry : newMapping.entrySet()) { + if (!localizationEntries.containsKey(newEntry.getKey()) || (newEntry.getValue() != null && !newEntry.getValue().isEmpty())) { + localizationEntries.put(newEntry.getKey(), newEntry.getValue()); + } + } + }); + this.localizations.getLanguagesInfo().putAll(localizations.getLanguagesInfo()); + this.localizations.getDescriptions().putAll(localizations.getDescriptions()); + + int maxId = 0; + for (final Map entries : this.localizations.getLanguages().values()) { + for (final String locId : entries.keySet()) { + final int intId = parseNumericId(locId); + if (intId > -1) { + maxId = Math.max(maxId, intId); + } + } + } + this.localizations.setCurId(maxId); + + this.localizations.setPrefix(localizations.getPrefix()); + + this.intLookup.clear(); + } + + if (this.currentLanguageName == null) { + this.currentLanguageName = "en"; + } + this.setLanguage(this.currentLanguageName); + } + + @Override + public void setLanguage(final String lang) { + if (!this.localizations.getLanguages().containsKey(lang)) { + return; + } + + this.currentLanguageName = lang; + this.currentLanguage = this.localizations.getLanguages().get(lang); + this.intLookup.clear(); + } + + @Override + public List getLanguages() { + if (this.localizations.getLanguagesInfo() == null || this.localizations.getLanguagesInfo().isEmpty()) { + return List.of(); + } + return List.copyOf(this.localizations.getLanguagesInfo().values()); + } + + @Override + public String getCurrentLanguage() { + return this.currentLanguageName; + } + + @Override + public String translate(final Integer id) { + String result = this.intLookup.get(id); + if (result != null) { + return result; + } + + result = this.translate(String.valueOf(id)); + this.intLookup.put(id, result); + return result; + } + + @Override + public String translate(final Integer id, final String defaultText) { + String result = this.translate(id); + if (result.startsWith(NOT_FOUND_PREFIX)) { + result = defaultText; + } + return result; + } + + @Override + public String translate(final String id) { + if (this.currentLanguage == null) { + return NOT_FOUND_PREFIX + id + "}"; + } + return this.currentLanguage.computeIfAbsent(id, key -> NOT_FOUND_PREFIX + key + "}"); + } + + @Override + public String translate(final String id, final String defaultText) { + String result = this.translate(id); + if (result.startsWith(NOT_FOUND_PREFIX)) { + result = defaultText; + } + return result; + } + + @Override + public void put(final String lang, final String id, final String value) { + final Map mapping = this.localizations.getLanguages().get(lang); + if (mapping == null) { + return; + } + + mapping.put(id, value); + if (!this.localizations.getLanguagesInfo().containsKey(lang)) { + this.localizations.getLanguagesInfo().put(lang, new Language(lang, lang)); + } + + this.intLookup.clear(); + } + + @Override + public void remove(final String id) { + this.localizations.getLanguages().forEach((k, v) -> v.remove(id)); + this.intLookup.clear(); + } + + @Override + public Integer getNextId() { + final int result = this.localizations.getCurId() + 1; + this.localizations.setCurId(result); + return Integer.valueOf(result); + } + + @Override + public Localizations getLocalizations() { + return this.localizations; + } + + private static int parseNumericId(final String id) { + try { + return Integer.parseInt(id); + } + catch (final NumberFormatException e) { + final int dashIndex = id.lastIndexOf("-"); + if (dashIndex == -1 || dashIndex == id.length() - 1) { + return -1; + } + + try { + return Integer.parseInt(id.substring(dashIndex + 1)); + } + catch (final NumberFormatException ex) { + return -1; + } + } + } + +} diff --git a/src/main/java/de/extio/game_engine/i18n/Localizations.java b/src/main/java/de/extio/game_engine/i18n/Localizations.java new file mode 100644 index 0000000..5e01af8 --- /dev/null +++ b/src/main/java/de/extio/game_engine/i18n/Localizations.java @@ -0,0 +1,81 @@ +package de.extio.game_engine.i18n; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Map.Entry; + +/** + * This is the container holding localized texts (i18n) + */ +public final class Localizations { + + private String prefix; + + private Map> languages = new LinkedHashMap<>(); + + private Map languagesInfo = new LinkedHashMap<>(); + + private Map descriptions = new HashMap<>(); + + private int curId; + + public Localizations() { + + } + + public Localizations(final Localizations other) { + this.languages = new LinkedHashMap<>(); + for (final Entry> entry : other.languages.entrySet()) { + this.languages.put(entry.getKey(), new LinkedHashMap<>(entry.getValue())); + } + this.languagesInfo = new LinkedHashMap<>(); + for (final Entry entry : other.languagesInfo.entrySet()) { + this.languagesInfo.put(entry.getKey(), new Language(entry.getValue())); + } + this.descriptions = new HashMap<>(other.descriptions); + this.curId = other.curId; + this.prefix = other.prefix; + } + + public Map> getLanguages() { + return this.languages; + } + + public void setLanguages(final Map> languages) { + this.languages = languages; + } + + public Map getLanguagesInfo() { + return this.languagesInfo; + } + + public void setLanguagesInfo(final Map languagesInfo) { + this.languagesInfo = languagesInfo; + } + + public int getCurId() { + return this.curId; + } + + public void setCurId(final int curId) { + this.curId = curId; + } + + public Map getDescriptions() { + return this.descriptions; + } + + public void setDescriptions(final Map descriptions) { + this.descriptions = descriptions; + } + + public String getPrefix() { + return this.prefix; + } + + public void setPrefix(final String prefix) { + this.prefix = prefix; + } + +} diff --git a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 24817c5..be4c3d2 100644 --- a/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -4,3 +4,4 @@ de.extio.game_engine.renderer.RendererAutoConfiguration de.extio.game_engine.renderer.g2d.G2DAutoConfiguration de.extio.game_engine.storage.StorageServiceAutoConfiguration de.extio.game_engine.module.ModuleManagerAutoConfiguration +de.extio.game_engine.i18n.LocalizationAutoConfiguration diff --git a/src/test/java/de/extio/game_engine/i18n/LocalizationManagerTest.java b/src/test/java/de/extio/game_engine/i18n/LocalizationManagerTest.java new file mode 100644 index 0000000..6a97611 --- /dev/null +++ b/src/test/java/de/extio/game_engine/i18n/LocalizationManagerTest.java @@ -0,0 +1,320 @@ +package de.extio.game_engine.i18n; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class LocalizationManagerTest { + + private LocalizationManager localizationManager; + + @BeforeEach + void setUp() { + this.localizationManager = new LocalizationManagerImpl(); + } + + @Test + void testReset() { + this.localizationManager.reset(); + assertNotNull(this.localizationManager.getLocalizations()); + assertTrue(this.localizationManager.getLanguages().isEmpty()); + } + + @Test + void testResetEntries() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + this.localizationManager.setLanguage("en"); + + this.localizationManager.resetEntries(); + + assertTrue(localizations.getLanguages().get("en").isEmpty()); + } + + @Test + void testLoadLocalizations() { + Localizations localizationsToSave = new Localizations(); + Language english = new Language("English", "en"); + Map englishEntries = new LinkedHashMap<>(Map.of("greeting", "Hello", "farewell", "Goodbye")); + localizationsToSave.getLanguagesInfo().put("en", english); + localizationsToSave.getLanguages().put("en", englishEntries); + localizationsToSave.setPrefix("i18n."); + localizationsToSave.setCurId(1); + + byte[] serialized = de.extio.game_engine.util.ObjectSerialization.serialize( + localizationsToSave, false, false, false, null, digest -> { + }); + + InputStream stream = new ByteArrayInputStream(serialized); + + this.localizationManager.load(stream); + + Localizations loaded = this.localizationManager.getLocalizations(); + assertNotNull(loaded); + assertEquals("i18n.", loaded.getPrefix()); + + List languages = this.localizationManager.getLanguages(); + assertEquals(1, languages.size()); + assertEquals("en", languages.getFirst().getShortName()); + + assertEquals("en", this.localizationManager.getCurrentLanguage()); + assertEquals("Hello", this.localizationManager.translate("greeting")); + assertEquals("Goodbye", this.localizationManager.translate("farewell")); + } + + @Test + void testSetLanguage() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + Language german = new Language("German", "de"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguagesInfo().put("de", german); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + localizations.getLanguages().put("de", new LinkedHashMap<>(Map.of("greeting", "Hallo"))); + + this.localizationManager.setLanguage("de"); + + assertEquals("de", this.localizationManager.getCurrentLanguage()); + } + + @Test + void testSetLanguageInvalid() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + this.localizationManager.setLanguage("en"); + + this.localizationManager.setLanguage("fr"); + + assertEquals("en", this.localizationManager.getCurrentLanguage()); + } + + @Test + void testGetLanguages() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + Language german = new Language("German", "de"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguagesInfo().put("de", german); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + localizations.getLanguages().put("de", new LinkedHashMap<>(Map.of("greeting", "Hallo"))); + + List languages = this.localizationManager.getLanguages(); + + assertEquals(2, languages.size()); + assertTrue(languages.stream().anyMatch(lang -> "en".equals(lang.getShortName()))); + assertTrue(languages.stream().anyMatch(lang -> "de".equals(lang.getShortName()))); + } + + @Test + void testGetCurrentLanguage() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + this.localizationManager.setLanguage("en"); + + assertEquals("en", this.localizationManager.getCurrentLanguage()); + } + + @Test + void testTranslateWithIntegerId() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("1", "Hello"))); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate(1); + + assertEquals("Hello", result); + } + + @Test + void testTranslateWithIntegerIdNotFound() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate(999); + + assertTrue(result.startsWith(LocalizationManagerImpl.NOT_FOUND_PREFIX)); + } + + @Test + void testTranslateWithIntegerIdAndDefault() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate(999, "Default Text"); + + assertEquals("Default Text", result); + } + + @Test + void testTranslateWithStringId() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate("greeting"); + + assertEquals("Hello", result); + } + + @Test + void testTranslateWithStringIdNotFound() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate("unknown"); + + assertTrue(result.startsWith(LocalizationManagerImpl.NOT_FOUND_PREFIX)); + } + + @Test + void testTranslateWithStringIdAndDefault() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + this.localizationManager.setLanguage("en"); + + String result = this.localizationManager.translate("unknown", "Default Text"); + + assertEquals("Default Text", result); + } + + @Test + void testPut() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + this.localizationManager.setLanguage("en"); + + this.localizationManager.put("en", "greeting", "Hello"); + String result = this.localizationManager.translate("greeting"); + + assertEquals("Hello", result); + } + + @Test + void testPutInvalidLanguage() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>()); + + this.localizationManager.put("fr", "greeting", "Bonjour"); + + assertTrue(localizations.getLanguages().get("en").isEmpty()); + } + + @Test + void testRemove() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + Map entries = new LinkedHashMap<>(Map.of("greeting", "Hello", "farewell", "Goodbye")); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", entries); + this.localizationManager.setLanguage("en"); + + this.localizationManager.remove("greeting"); + + assertFalse(localizations.getLanguages().get("en").containsKey("greeting")); + assertTrue(localizations.getLanguages().get("en").containsKey("farewell")); + } + + @Test + void testGetNextId() { + Localizations localizations = this.localizationManager.getLocalizations(); + localizations.setCurId(5); + + Integer nextId = this.localizationManager.getNextId(); + + assertEquals(6, nextId); + assertEquals(6, localizations.getCurId()); + } + + @Test + void testGetNextIdIncremental() { + Localizations localizations = this.localizationManager.getLocalizations(); + localizations.setCurId(0); + + Integer id1 = this.localizationManager.getNextId(); + Integer id2 = this.localizationManager.getNextId(); + Integer id3 = this.localizationManager.getNextId(); + + assertEquals(1, id1); + assertEquals(2, id2); + assertEquals(3, id3); + } + + @Test + void testGetLocalizations() { + Localizations localizations = this.localizationManager.getLocalizations(); + + assertNotNull(localizations); + assertEquals(localizations, this.localizationManager.getLocalizations()); + } + + @Test + void testTranslateCaching() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("1", "Hello"))); + this.localizationManager.setLanguage("en"); + + String result1 = this.localizationManager.translate(1); + String result2 = this.localizationManager.translate(1); + + assertEquals("Hello", result1); + assertEquals("Hello", result2); + assertEquals(result1, result2); + } + + @Test + void testMultipleLanguages() { + Localizations localizations = this.localizationManager.getLocalizations(); + Language english = new Language("English", "en"); + Language german = new Language("German", "de"); + localizations.getLanguagesInfo().put("en", english); + localizations.getLanguagesInfo().put("de", german); + localizations.getLanguages().put("en", new LinkedHashMap<>(Map.of("greeting", "Hello"))); + localizations.getLanguages().put("de", new LinkedHashMap<>(Map.of("greeting", "Hallo"))); + + this.localizationManager.setLanguage("en"); + String englishGreeting = this.localizationManager.translate("greeting"); + + this.localizationManager.setLanguage("de"); + String germanGreeting = this.localizationManager.translate("greeting"); + + assertEquals("Hello", englishGreeting); + assertEquals("Hallo", germanGreeting); + } +}