diff --git a/CI/before_install.osx.sh b/CI/before_install.osx.sh index b3463aa9b8..b5bb35957f 100755 --- a/CI/before_install.osx.sh +++ b/CI/before_install.osx.sh @@ -13,6 +13,8 @@ brew update --quiet command -v ccache >/dev/null 2>&1 || brew install ccache command -v cmake >/dev/null 2>&1 || brew install cmake command -v qmake >/dev/null 2>&1 || brew install qt@5 +command -v pkgdata >/dev/null 2>&1 || brew install icu4c +brew install yaml-cpp export PATH="/usr/local/opt/qt@5/bin:$PATH" # needed to use qmake in none default path as qt now points to qt6 ccache --version diff --git a/CI/before_script.android.sh b/CI/before_script.android.sh index 80a3f11e57..47655f8ace 100755 --- a/CI/before_script.android.sh +++ b/CI/before_script.android.sh @@ -6,6 +6,20 @@ sed -i s/"NOT FFVER_OK"/"FALSE"/ CMakeLists.txt mkdir -p build cd build +# Build a version of ICU for the host so that it can use the tools during the cross-compilation +mkdir -p icu-host-build +cd icu-host-build +if [ -r ../extern/fetched/icu/icu4c/source/configure ]; then + ICU_SOURCE_DIR=../extern/fetched/icu/icu4c/source +else + wget https://github.com/unicode-org/icu/archive/refs/tags/release-70-1.zip + unzip release-70-1.zip + ICU_SOURCE_DIR=./icu-release-70-1/icu4c/source +fi +${ICU_SOURCE_DIR}/configure --disable-tests --disable-samples --disable-icuio --disable-extras CC="ccache gcc" CXX="ccache g++" +make -j $(nproc) +cd .. + cmake \ -DCMAKE_TOOLCHAIN_FILE=/android-ndk-r22/build/cmake/android.toolchain.cmake \ -DANDROID_ABI=arm64-v8a \ @@ -26,4 +40,7 @@ cmake \ -DBUILD_BULLETOBJECTTOOL=OFF \ -DOPENMW_USE_SYSTEM_MYGUI=OFF \ -DOPENMW_USE_SYSTEM_SQLITE3=OFF \ +-DOPENMW_USE_SYSTEM_YAML_CPP=OFF \ +-DOPENMW_USE_SYSTEM_ICU=OFF \ +-DOPENMW_ICU_HOST_BUILD_DIR="$(pwd)/icu-host-build" \ .. diff --git a/CI/before_script.msvc.sh b/CI/before_script.msvc.sh index 563a65595b..5cfff39b3d 100644 --- a/CI/before_script.msvc.sh +++ b/CI/before_script.msvc.sh @@ -512,6 +512,8 @@ if ! [ -z $USE_CCACHE ]; then add_cmake_opts "-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache" fi +ICU_VER="70_1" + echo echo "===================================" echo "Starting prebuild on MSVC${MSVC_DISPLAY_YEAR} WIN${BITS}" @@ -598,6 +600,11 @@ if [ -z $SKIP_DOWNLOAD ]; then git clone -b release-1.10.0 https://github.com/google/googletest.git fi fi + + # ICU + download "ICU ${ICU_VER/_/.}"\ + "https://github.com/unicode-org/icu/releases/download/release-${ICU_VER/_/-}/icu4c-${ICU_VER}-Win${BITS}-MSVC2019.zip" \ + "icu4c-${ICU_VER}-Win${BITS}-MSVC2019.zip" fi cd .. #/.. @@ -1027,6 +1034,24 @@ if [ ! -z $TEST_FRAMEWORK ]; then fi +cd $DEPS +echo +# ICU +printf "ICU ${ICU_VER/_/.}... " +{ + if [ -d ICU ]; then + printf "Exists. " + elif [ -z $SKIP_EXTRACT ]; then + rm -rf ICU + eval 7z x -y icu4c-${ICU_VER}-Win${BITS}-MSVC2019.zip -o$(real_pwd)/ICU $STRIP + fi + export ICU_ROOT="$(real_pwd)/ICU" + add_cmake_opts -DICU_INCLUDE_DIR="${ICU_ROOT}/include" \ + -DICU_LIBRARY="${ICU_ROOT}/lib${BITS}/icuuc.lib " \ + -DICU_DEBUG=ON + echo Done. +} + echo cd $DEPS_INSTALL/.. echo @@ -1034,6 +1059,7 @@ echo "Setting up OpenMW build..." add_cmake_opts -DOPENMW_MP_BUILD=on add_cmake_opts -DCMAKE_INSTALL_PREFIX="${INSTALL_PREFIX}" add_cmake_opts -DOPENMW_USE_SYSTEM_SQLITE3=OFF +add_cmake_opts -DOPENMW_USE_SYSTEM_YAML_CPP=OFF if [ ! -z $CI ]; then case $STEP in components ) diff --git a/CI/install_debian_deps.sh b/CI/install_debian_deps.sh index 93f701d011..c089558208 100755 --- a/CI/install_debian_deps.sh +++ b/CI/install_debian_deps.sh @@ -20,7 +20,7 @@ declare -rA GROUPED_DEPS=( libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev libsdl2-dev libqt5opengl5-dev libopenal-dev libunshield-dev libtinyxml-dev libbullet-dev liblz4-dev libpng-dev libjpeg-dev libluajit-5.1-dev - librecast-dev libsqlite3-dev ca-certificates + librecast-dev libsqlite3-dev ca-certificates libicu-dev libyaml-cpp-dev " # These dependencies can alternatively be built and linked statically. diff --git a/CMakeLists.txt b/CMakeLists.txt index bc4fa56e25..dbb88e14f8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -248,6 +248,23 @@ set(USED_OSG_PLUGINS osgdb_serializers_osg osgdb_tga) + +option(OPENMW_USE_SYSTEM_ICU "Use system ICU library instead of internal. If disabled, requires autotools" ON) +if(OPENMW_USE_SYSTEM_ICU) + find_package(ICU COMPONENTS uc i18n) +endif() + +option(OPENMW_USE_SYSTEM_YAML_CPP "Use system yaml-cpp library instead of internal." ON) +if(OPENMW_USE_SYSTEM_YAML_CPP) + set(_yaml_cpp_static_default OFF) +else() + set(_yaml_cpp_static_default ON) +endif() +option(YAML_CPP_STATIC "Link static build of yaml-cpp into the binaries" ${_yaml_cpp_static_default}) +if (OPENMW_USE_SYSTEM_YAML_CPP) + find_package(yaml-cpp) +endif() + add_subdirectory(extern) # Sound setup @@ -442,6 +459,7 @@ include_directories( ${LUA_INCLUDE_DIR} ${SOL_INCLUDE_DIR} ${SOL_CONFIG_DIR} + ${ICU_INCLUDE_DIRS} ) link_directories(${SDL2_LIBRARY_DIRS} ${Boost_LIBRARY_DIRS}) diff --git a/apps/openmw/mwlua/context.hpp b/apps/openmw/mwlua/context.hpp index 7ff584d8cc..124e7f06be 100644 --- a/apps/openmw/mwlua/context.hpp +++ b/apps/openmw/mwlua/context.hpp @@ -7,7 +7,7 @@ namespace LuaUtil { class LuaState; class UserdataSerializer; - class I18nManager; + class L10nManager; } namespace MWLua @@ -21,7 +21,7 @@ namespace MWLua LuaManager* mLuaManager; LuaUtil::LuaState* mLua; LuaUtil::UserdataSerializer* mSerializer; - LuaUtil::I18nManager* mI18n; + LuaUtil::L10nManager* mL10n; WorldView* mWorldView; LocalEventQueue* mLocalEventQueue; GlobalEventQueue* mGlobalEventQueue; diff --git a/apps/openmw/mwlua/luabindings.cpp b/apps/openmw/mwlua/luabindings.cpp index b47e1f2dec..c88045ae84 100644 --- a/apps/openmw/mwlua/luabindings.cpp +++ b/apps/openmw/mwlua/luabindings.cpp @@ -1,7 +1,7 @@ #include "luabindings.hpp" #include -#include +#include #include "../mwbase/environment.hpp" #include "../mwbase/statemanager.hpp" @@ -52,7 +52,12 @@ namespace MWLua context.mGlobalEventQueue->push_back({std::move(eventName), LuaUtil::serialize(eventData, context.mSerializer)}); }; addTimeBindings(api, context, false); - api["i18n"] = [i18n=context.mI18n](const std::string& context) { return i18n->getContext(context); }; + api["l10n"] = [l10n=context.mL10n](const std::string& context, const sol::object &fallbackLocale) { + if (fallbackLocale == sol::nil) + return l10n->getContext(context); + else + return l10n->getContext(context, fallbackLocale.as()); + }; const MWWorld::Store* gmst = &MWBase::Environment::get().getWorld()->getStore().get(); api["getGMST"] = [lua=context.mLua, gmst](const std::string& setting) -> sol::object { diff --git a/apps/openmw/mwlua/luamanagerimp.cpp b/apps/openmw/mwlua/luamanagerimp.cpp index df49862df6..e2037049d8 100644 --- a/apps/openmw/mwlua/luamanagerimp.cpp +++ b/apps/openmw/mwlua/luamanagerimp.cpp @@ -30,7 +30,7 @@ namespace MWLua LuaManager::LuaManager(const VFS::Manager* vfs, const std::string& libsDir) : mLua(vfs, &mConfiguration) , mUiResourceManager(vfs) - , mI18n(vfs, &mLua) + , mL10n(vfs, &mLua) { Log(Debug::Info) << "Lua version: " << LuaUtil::getLuaVersion(); mLua.addInternalLibSearchPath(libsDir); @@ -57,7 +57,7 @@ namespace MWLua context.mIsGlobal = true; context.mLuaManager = this; context.mLua = &mLua; - context.mI18n = &mI18n; + context.mL10n = &mL10n; context.mWorldView = &mWorldView; context.mLocalEventQueue = &mLocalEvents; context.mGlobalEventQueue = &mGlobalEvents; @@ -67,10 +67,10 @@ namespace MWLua localContext.mIsGlobal = false; localContext.mSerializer = mLocalSerializer.get(); - mI18n.init(); - std::vector preferredLanguages; - Misc::StringUtils::split(Settings::Manager::getString("i18n preferred languages", "Lua"), preferredLanguages, ", "); - mI18n.setPreferredLanguages(preferredLanguages); + mL10n.init(); + std::vector preferredLocales; + Misc::StringUtils::split(Settings::Manager::getString("preferred locales", "General"), preferredLocales, ", "); + mL10n.setPreferredLocales(preferredLocales); initObjectBindingsForGlobalScripts(context); initCellBindingsForGlobalScripts(context); diff --git a/apps/openmw/mwlua/luamanagerimp.hpp b/apps/openmw/mwlua/luamanagerimp.hpp index fed6dc9dc7..e6f5af78be 100644 --- a/apps/openmw/mwlua/luamanagerimp.hpp +++ b/apps/openmw/mwlua/luamanagerimp.hpp @@ -4,7 +4,7 @@ #include #include -#include +#include #include #include @@ -102,7 +102,7 @@ namespace MWLua LuaUtil::ScriptsConfiguration mConfiguration; LuaUtil::LuaState mLua; LuaUi::ResourceManager mUiResourceManager; - LuaUtil::I18nManager mI18n; + LuaUtil::L10nManager mL10n; sol::table mNearbyPackage; sol::table mUserInterfacePackage; sol::table mCameraPackage; diff --git a/apps/openmw_test_suite/CMakeLists.txt b/apps/openmw_test_suite/CMakeLists.txt index 854526dc8c..3b12e7c227 100644 --- a/apps/openmw_test_suite/CMakeLists.txt +++ b/apps/openmw_test_suite/CMakeLists.txt @@ -22,7 +22,7 @@ if (GTEST_FOUND AND GMOCK_FOUND) lua/test_utilpackage.cpp lua/test_serialization.cpp lua/test_configuration.cpp - lua/test_i18n.cpp + lua/test_l10n.cpp lua/test_storage.cpp lua/test_ui_content.cpp diff --git a/apps/openmw_test_suite/lua/test_i18n.cpp b/apps/openmw_test_suite/lua/test_i18n.cpp deleted file mode 100644 index 427482be64..0000000000 --- a/apps/openmw_test_suite/lua/test_i18n.cpp +++ /dev/null @@ -1,110 +0,0 @@ -#include "gmock/gmock.h" -#include - -#include - -#include -#include - -#include "testing_util.hpp" - -namespace -{ - using namespace testing; - - TestFile invalidScript("not a script"); - TestFile incorrectScript("return { incorrectSection = {}, engineHandlers = { incorrectHandler = function() end } }"); - TestFile emptyScript(""); - - TestFile test1En(R"X( -return { - good_morning = "Good morning.", - you_have_arrows = { - one = "You have one arrow.", - other = "You have %{count} arrows.", - }, -} -)X"); - - TestFile test1De(R"X( -return { - good_morning = "Guten Morgen.", - you_have_arrows = { - one = "Du hast ein Pfeil.", - other = "Du hast %{count} Pfeile.", - }, - ["Hello %{name}!"] = "Hallo %{name}!", -} -)X"); - -TestFile test2En(R"X( -return { - good_morning = "Morning!", - you_have_arrows = "Arrows count: %{count}", -} -)X"); - - TestFile invalidTest2De(R"X( -require('math') -return {} -)X"); - - struct LuaI18nTest : Test - { - std::unique_ptr mVFS = createTestVFS({ - {"i18n/Test1/en.lua", &test1En}, - {"i18n/Test1/de.lua", &test1De}, - {"i18n/Test2/en.lua", &test2En}, - {"i18n/Test2/de.lua", &invalidTest2De}, - }); - - LuaUtil::ScriptsConfiguration mCfg; - std::string mLibsPath = (Files::TargetPathType("openmw_test_suite").getLocalPath() / "resources" / "lua_libs").string(); - }; - - TEST_F(LuaI18nTest, I18n) - { - internal::CaptureStdout(); - LuaUtil::LuaState lua{mVFS.get(), &mCfg}; - sol::state& l = lua.sol(); - LuaUtil::I18nManager i18n(mVFS.get(), &lua); - lua.addInternalLibSearchPath(mLibsPath); - i18n.init(); - i18n.setPreferredLanguages({"de", "en"}); - EXPECT_THAT(internal::GetCapturedStdout(), "I18n preferred languages: de en\n"); - - internal::CaptureStdout(); - l["t1"] = i18n.getContext("Test1"); - EXPECT_THAT(internal::GetCapturedStdout(), "Language file \"i18n/Test1/de.lua\" is enabled\n"); - - internal::CaptureStdout(); - l["t2"] = i18n.getContext("Test2"); - { - std::string output = internal::GetCapturedStdout(); - EXPECT_THAT(output, HasSubstr("Can not load i18n/Test2/de.lua")); - EXPECT_THAT(output, HasSubstr("Language file \"i18n/Test2/en.lua\" is enabled")); - } - - EXPECT_EQ(get(l, "t1('good_morning')"), "Guten Morgen."); - EXPECT_EQ(get(l, "t1('you_have_arrows', {count=1})"), "Du hast ein Pfeil."); - EXPECT_EQ(get(l, "t1('you_have_arrows', {count=5})"), "Du hast 5 Pfeile."); - EXPECT_EQ(get(l, "t1('Hello %{name}!', {name='World'})"), "Hallo World!"); - EXPECT_EQ(get(l, "t2('good_morning')"), "Morning!"); - EXPECT_EQ(get(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3"); - - internal::CaptureStdout(); - i18n.setPreferredLanguages({"en", "de"}); - EXPECT_THAT(internal::GetCapturedStdout(), - "I18n preferred languages: en de\n" - "Language file \"i18n/Test1/en.lua\" is enabled\n" - "Language file \"i18n/Test2/en.lua\" is enabled\n"); - - EXPECT_EQ(get(l, "t1('good_morning')"), "Good morning."); - EXPECT_EQ(get(l, "t1('you_have_arrows', {count=1})"), "You have one arrow."); - EXPECT_EQ(get(l, "t1('you_have_arrows', {count=5})"), "You have 5 arrows."); - EXPECT_EQ(get(l, "t1('Hello %{name}!', {name='World'})"), "Hello World!"); - EXPECT_EQ(get(l, "t2('good_morning')"), "Morning!"); - EXPECT_EQ(get(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3"); - } - -} diff --git a/apps/openmw_test_suite/lua/test_l10n.cpp b/apps/openmw_test_suite/lua/test_l10n.cpp new file mode 100644 index 0000000000..c989d56e72 --- /dev/null +++ b/apps/openmw_test_suite/lua/test_l10n.cpp @@ -0,0 +1,157 @@ +#include "gmock/gmock.h" +#include + +#include + +#include +#include + +#include "testing_util.hpp" + +namespace +{ + using namespace testing; + + TestFile invalidScript("not a script"); + TestFile incorrectScript("return { incorrectSection = {}, engineHandlers = { incorrectHandler = function() end } }"); + TestFile emptyScript(""); + + TestFile test1En(R"X( +good_morning: "Good morning." +you_have_arrows: |- + {count, plural, + =0{You have no arrows.} + one{You have one arrow.} + other{You have {count} arrows.} + } +pc_must_come: |- + {PCGender, select, + male {He is} + female {She is} + other {They are} + } coming with us. +quest_completion: "The quest is {done, number, percent} complete." +ordinal: "You came in {num, ordinal} place." +spellout: "There {num, plural, one{is {num, spellout} thing} other{are {num, spellout} things}}." +duration: "It took {num, duration}" +numbers: "{int} and {double, number, integer} are integers, but {double} is a double" +rounding: "{value, number, :: .00}" +)X"); + + TestFile test1De(R"X( +good_morning: "Guten Morgen." +you_have_arrows: |- + {count, plural, + one{Du hast ein Pfeil.} + other{Du hast {count} Pfeile.} + } +"Hello {name}!": "Hallo {name}!" +)X"); + + TestFile test1EnUS(R"X( +currency: "You have {money, number, currency}" +)X"); + +TestFile test2En(R"X( +good_morning: "Morning!" +you_have_arrows: "Arrows count: {count}" +)X"); + + struct LuaL10nTest : Test + { + std::unique_ptr mVFS = createTestVFS({ + {"l10n/Test1/en.yaml", &test1En}, + {"l10n/Test1/en_US.yaml", &test1EnUS}, + {"l10n/Test1/de.yaml", &test1De}, + {"l10n/Test2/en.yaml", &test2En}, + {"l10n/Test3/en.yaml", &test1En}, + {"l10n/Test3/de.yaml", &test1De}, + }); + + LuaUtil::ScriptsConfiguration mCfg; + }; + + TEST_F(LuaL10nTest, L10n) + { + internal::CaptureStdout(); + LuaUtil::LuaState lua{mVFS.get(), &mCfg}; + sol::state& l = lua.sol(); + LuaUtil::L10nManager l10n(mVFS.get(), &lua); + l10n.init(); + l10n.setPreferredLocales({"de", "en"}); + EXPECT_THAT(internal::GetCapturedStdout(), "Preferred locales: de en\n"); + + internal::CaptureStdout(); + l["t1"] = l10n.getContext("Test1"); + EXPECT_THAT(internal::GetCapturedStdout(), + "Fallback locale: en\n" + "Language file \"l10n/Test1/de.yaml\" is enabled\n" + "Language file \"l10n/Test1/en.yaml\" is enabled\n"); + + internal::CaptureStdout(); + l["t2"] = l10n.getContext("Test2"); + { + std::string output = internal::GetCapturedStdout(); + EXPECT_THAT(output, HasSubstr("Language file \"l10n/Test2/en.yaml\" is enabled")); + } + + EXPECT_EQ(get(l, "t1('good_morning')"), "Guten Morgen."); + EXPECT_EQ(get(l, "t1('you_have_arrows', {count=1})"), "Du hast ein Pfeil."); + EXPECT_EQ(get(l, "t1('you_have_arrows', {count=5})"), "Du hast 5 Pfeile."); + EXPECT_EQ(get(l, "t1('Hello {name}!', {name='World'})"), "Hallo World!"); + EXPECT_EQ(get(l, "t2('good_morning')"), "Morning!"); + EXPECT_EQ(get(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3"); + + internal::CaptureStdout(); + l10n.setPreferredLocales({"en", "de"}); + EXPECT_THAT(internal::GetCapturedStdout(), + "Preferred locales: en de\n" + "Language file \"l10n/Test1/en.yaml\" is enabled\n" + "Language file \"l10n/Test1/de.yaml\" is enabled\n" + "Language file \"l10n/Test2/en.yaml\" is enabled\n"); + + EXPECT_EQ(get(l, "t1('good_morning')"), "Good morning."); + EXPECT_EQ(get(l, "t1('you_have_arrows', {count=1})"), "You have one arrow."); + EXPECT_EQ(get(l, "t1('you_have_arrows', {count=5})"), "You have 5 arrows."); + EXPECT_EQ(get(l, "t1('pc_must_come', {PCGender=\"male\"})"), "He is coming with us."); + EXPECT_EQ(get(l, "t1('pc_must_come', {PCGender=\"female\"})"), "She is coming with us."); + EXPECT_EQ(get(l, "t1('pc_must_come', {PCGender=\"blah\"})"), "They are coming with us."); + EXPECT_EQ(get(l, "t1('pc_must_come', {PCGender=\"other\"})"), "They are coming with us."); + EXPECT_EQ(get(l, "t1('quest_completion', {done=0.1})"), "The quest is 10% complete."); + EXPECT_EQ(get(l, "t1('quest_completion', {done=1})"), "The quest is 100% complete."); + EXPECT_EQ(get(l, "t1('ordinal', {num=1})"), "You came in 1st place."); + EXPECT_EQ(get(l, "t1('ordinal', {num=100})"), "You came in 100th place."); + EXPECT_EQ(get(l, "t1('spellout', {num=1})"), "There is one thing."); + EXPECT_EQ(get(l, "t1('spellout', {num=100})"), "There are one hundred things."); + EXPECT_EQ(get(l, "t1('duration', {num=100})"), "It took 1:40"); + EXPECT_EQ(get(l, "t1('numbers', {int=123, double=123.456})"), "123 and 123 are integers, but 123.456 is a double"); + EXPECT_EQ(get(l, "t1('rounding', {value=123.456789})"), "123.46"); + // Check that failed messages display the key instead of an empty string + EXPECT_EQ(get(l, "t1('{mismatched_braces')"), "{mismatched_braces"); + EXPECT_EQ(get(l, "t1('{unknown_arg}')"), "{unknown_arg}"); + EXPECT_EQ(get(l, "t1('{num, integer}', {num=1})"), "{num, integer}"); + // Doesn't give a valid currency symbol with `en`. Not that openmw is designed for real world currency. + l10n.setPreferredLocales({"en-US", "de"}); + EXPECT_EQ(get(l, "t1('currency', {money=10000.10})"), "You have $10,000.10"); + // Note: Not defined in English localisation file, so we fall back to the German before falling back to the key + EXPECT_EQ(get(l, "t1('Hello {name}!', {name='World'})"), "Hallo World!"); + EXPECT_EQ(get(l, "t2('good_morning')"), "Morning!"); + EXPECT_EQ(get(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3"); + + // Test that locales with variants and country codes fall back to more generic locales + internal::CaptureStdout(); + l10n.setPreferredLocales({"en-GB-oed", "de"}); + EXPECT_THAT(internal::GetCapturedStdout(), + "Preferred locales: en_GB_OED de\n" + "Language file \"l10n/Test1/en.yaml\" is enabled\n" + "Language file \"l10n/Test1/de.yaml\" is enabled\n" + "Language file \"l10n/Test2/en.yaml\" is enabled\n"); + EXPECT_EQ(get(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3"); + + // Test setting fallback language + l["t3"] = l10n.getContext("Test3", "de"); + l10n.setPreferredLocales({"en"}); + EXPECT_EQ(get(l, "t3('Hello {name}!', {name='World'})"), "Hallo World!"); + } + +} diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt index af56b37dea..5a3292228e 100644 --- a/components/CMakeLists.txt +++ b/components/CMakeLists.txt @@ -29,7 +29,11 @@ endif (GIT_CHECKOUT) # source files add_component_dir (lua - luastate scriptscontainer utilpackage serialization configuration i18n storage + luastate scriptscontainer utilpackage serialization configuration l10n storage + ) + +add_component_dir (l10n + messagebundles ) add_component_dir (settings @@ -395,6 +399,8 @@ target_link_libraries(components Base64 SQLite::SQLite3 smhasher + ${ICU_LIBRARIES} + yaml-cpp ) target_link_libraries(components ${BULLET_LIBRARIES}) diff --git a/components/l10n/messagebundles.cpp b/components/l10n/messagebundles.cpp new file mode 100644 index 0000000000..9560e60d3b --- /dev/null +++ b/components/l10n/messagebundles.cpp @@ -0,0 +1,167 @@ +#include "messagebundles.hpp" + +#include +#include +#include +#include + +#include + +namespace l10n +{ + MessageBundles::MessageBundles(const std::vector &preferredLocales, icu::Locale &fallbackLocale) : + mFallbackLocale(fallbackLocale) + { + setPreferredLocales(preferredLocales); + } + + void MessageBundles::setPreferredLocales(const std::vector &preferredLocales) + { + mPreferredLocales.clear(); + mPreferredLocaleStrings.clear(); + for (const icu::Locale &loc: preferredLocales) + { + mPreferredLocales.push_back(loc); + mPreferredLocaleStrings.push_back(loc.getName()); + // Try without variant or country if they are specified, starting with the most specific + if (strcmp(loc.getVariant(), "") != 0) + { + icu::Locale withoutVariant(loc.getLanguage(), loc.getCountry()); + mPreferredLocales.push_back(withoutVariant); + mPreferredLocaleStrings.push_back(withoutVariant.getName()); + } + if (strcmp(loc.getCountry(), "") != 0) + { + icu::Locale withoutCountry(loc.getLanguage()); + mPreferredLocales.push_back(withoutCountry); + mPreferredLocaleStrings.push_back(withoutCountry.getName()); + } + } + } + + std::string getErrorText(const UParseError &parseError) + { + icu::UnicodeString preContext(parseError.preContext), postContext(parseError.postContext); + std::string parseErrorString; + preContext.toUTF8String(parseErrorString); + postContext.toUTF8String(parseErrorString); + return parseErrorString; + } + + static bool checkSuccess(const icu::ErrorCode &status, const std::string &message, const UParseError parseError = UParseError()) + { + if (status.isFailure()) + { + std::string errorText = getErrorText(parseError); + if (errorText.size()) + { + Log(Debug::Error) << message << ": " << status.errorName() << " in \"" << errorText << "\""; + } + else + { + Log(Debug::Error) << message << ": " << status.errorName(); + } + } + return status.isSuccess(); + } + + void MessageBundles::load(std::istream &input, const icu::Locale& lang, const std::string &path) + { + try + { + YAML::Node data = YAML::Load(input); + std::string localeName = lang.getName(); + for (const auto& it: data) + { + std::string key = it.first.as(); + std::string value = it.second.as(); + icu::UnicodeString pattern = icu::UnicodeString::fromUTF8(value); + icu::ErrorCode status; + UParseError parseError; + icu::MessageFormat message(pattern, lang, parseError, status); + if (checkSuccess(status, std::string("Failed to create message ") + + key + " for locale " + lang.getName(), parseError)) + { + mBundles[localeName].insert(std::make_pair(key, message)); + } + } + } + catch (std::exception& e) + { + Log(Debug::Error) << "Can not load " << path << ": " << e.what(); + } + } + + const icu::MessageFormat * MessageBundles::findMessage(std::string_view key, const std::string &localeName) const + { + auto iter = mBundles.find(localeName); + if (iter != mBundles.end()) + { + auto message = iter->second.find(key.data()); + if (message != iter->second.end()) + { + return &(message->second); + } + } + return nullptr; + } + + std::string MessageBundles::formatMessage(std::string_view key, const std::map &args) const + { + std::vector argNames; + std::vector argValues; + for (auto& [key, value] : args) + { + argNames.push_back(icu::UnicodeString::fromUTF8(key)); + argValues.push_back(value); + } + return formatMessage(key, argNames, argValues); + } + + std::string MessageBundles::formatMessage(std::string_view key, const std::vector &argNames, const std::vector &args) const + { + icu::UnicodeString result; + std::string resultString; + icu::ErrorCode success; + + const icu::MessageFormat *message = nullptr; + for (auto &loc: mPreferredLocaleStrings) + { + message = findMessage(key, loc); + if (message) + break; + } + // If no requested locales included the message, try the fallback locale + if (!message) + message = findMessage(key, mFallbackLocale.getName()); + + if (message) + { + if (args.size() > 0 && argNames.size() > 0) + message->format(&argNames[0], &args[0], args.size(), result, success); + else + message->format(nullptr, nullptr, args.size(), result, success); + checkSuccess(success, std::string("Failed to format message ") + key.data()); + result.toUTF8String(resultString); + return resultString; + } + icu::Locale defaultLocale(NULL); + if (mPreferredLocales.size() > 0) + { + defaultLocale = mPreferredLocales[0]; + } + UParseError parseError; + icu::MessageFormat defaultMessage(icu::UnicodeString::fromUTF8(key), defaultLocale, parseError, success); + if (!checkSuccess(success, std::string("Failed to create message ") + key.data(), parseError)) + // If we can't parse the key as a pattern, just return the key + return std::string(key); + + if (args.size() > 0 && argNames.size() > 0) + defaultMessage.format(&argNames[0], &args[0], args.size(), result, success); + else + defaultMessage.format(nullptr, nullptr, args.size(), result, success); + checkSuccess(success, std::string("Failed to format message ") + key.data()); + result.toUTF8String(resultString); + return resultString; + } +} diff --git a/components/l10n/messagebundles.hpp b/components/l10n/messagebundles.hpp new file mode 100644 index 0000000000..02333fad2c --- /dev/null +++ b/components/l10n/messagebundles.hpp @@ -0,0 +1,55 @@ +#ifndef COMPONENTS_L10N_MESSAGEBUNDLES_H +#define COMPONENTS_L10N_MESSAGEBUNDLES_H + +#include +#include +#include +#include + +#include +#include + +namespace l10n +{ + /** + * @brief A collection of Message Bundles + * + * Class handling localised message storage and lookup, including fallback locales when messages are missing. + * + * If no fallback locale is provided (or a message fails to be found), the key will be formatted instead, + * or returned verbatim if formatting fails. + * + */ + class MessageBundles + { + public: + /* @brief Constructs an empty MessageBundles + * + * @param preferredLocales user-requested locales, in order of priority + * Each locale will be checked when looking up messages, in case some resource files are incomplete. + * For each locale which contains a country code or a variant, the locales obtained by removing first + * the variant, then the country code, will also be checked before moving on to the next locale in the list. + * @param fallbackLocale the fallback locale which should be used if messages cannot be found for the user + * preferred locales + */ + MessageBundles(const std::vector &preferredLocales, icu::Locale &fallbackLocale); + std::string formatMessage(std::string_view key, const std::map &args) const; + std::string formatMessage(std::string_view key, const std::vector &argNames, const std::vector &args) const; + void setPreferredLocales(const std::vector &preferredLocales); + const std::vector & getPreferredLocales() const { return mPreferredLocales; } + void load(std::istream &input, const icu::Locale &lang, const std::string &path); + bool isLoaded(icu::Locale loc) const { return mBundles.find(loc.getName()) != mBundles.end(); } + const icu::Locale & getFallbackLocale() const { return mFallbackLocale; } + + private: + // icu::Locale isn't hashable (or comparable), so we use the string form instead, which is canonicalized + std::unordered_map> mBundles; + const icu::Locale mFallbackLocale; + std::vector mPreferredLocaleStrings; + std::vector mPreferredLocales; + const icu::MessageFormat * findMessage(std::string_view key, const std::string &localeName) const; + }; + +} + +#endif // COMPONENTS_L10N_MESSAGEBUNDLES_H diff --git a/components/lua/i18n.cpp b/components/lua/i18n.cpp deleted file mode 100644 index e4d8e3ba78..0000000000 --- a/components/lua/i18n.cpp +++ /dev/null @@ -1,111 +0,0 @@ -#include "i18n.hpp" - -#include - -namespace sol -{ - template <> - struct is_automagical : std::false_type {}; -} - -namespace LuaUtil -{ - - void I18nManager::init() - { - mPreferredLanguages.push_back("en"); - sol::usertype ctx = mLua->sol().new_usertype("I18nContext"); - ctx[sol::meta_function::call] = &Context::translate; - try - { - mI18nLoader = mLua->loadInternalLib("i18n"); - sol::set_environment(mLua->newInternalLibEnvironment(), mI18nLoader); - } - catch (std::exception& e) - { - Log(Debug::Error) << "LuaUtil::I18nManager initialization failed: " << e.what(); - } - } - - void I18nManager::setPreferredLanguages(const std::vector& langs) - { - { - Log msg(Debug::Info); - msg << "I18n preferred languages:"; - for (const std::string& l : langs) - msg << " " << l; - } - mPreferredLanguages = langs; - for (auto& [_, context] : mContexts) - context.updateLang(this); - } - - void I18nManager::Context::readLangData(I18nManager* manager, const std::string& lang) - { - std::string path = "i18n/"; - path.append(mName); - path.append("/"); - path.append(lang); - path.append(".lua"); - if (!manager->mVFS->exists(path)) - return; - try - { - sol::protected_function dataFn = manager->mLua->loadFromVFS(path); - sol::environment emptyEnv(manager->mLua->sol(), sol::create); - sol::set_environment(emptyEnv, dataFn); - sol::table data = manager->mLua->newTable(); - data[lang] = call(dataFn); - call(mI18n["load"], data); - mLoadedLangs[lang] = true; - } - catch (std::exception& e) - { - Log(Debug::Error) << "Can not load " << path << ": " << e.what(); - } - } - - sol::object I18nManager::Context::translate(std::string_view key, const sol::object& data) - { - sol::object res = call(mI18n["translate"], key, data); - if (res != sol::nil) - return res; - - // If not found in a language file - register the key itself as a message. - std::string composedKey = call(mI18n["getLocale"]).get(); - composedKey.push_back('.'); - composedKey.append(key); - call(mI18n["set"], composedKey, key); - return call(mI18n["translate"], key, data); - } - - void I18nManager::Context::updateLang(I18nManager* manager) - { - for (const std::string& lang : manager->mPreferredLanguages) - { - if (mLoadedLangs[lang] == sol::nil) - readLangData(manager, lang); - if (mLoadedLangs[lang] != sol::nil) - { - Log(Debug::Verbose) << "Language file \"i18n/" << mName << "/" << lang << ".lua\" is enabled"; - call(mI18n["setLocale"], lang); - return; - } - } - Log(Debug::Warning) << "No language files for the preferred languages found in \"i18n/" << mName << "\""; - } - - sol::object I18nManager::getContext(const std::string& contextName) - { - if (mI18nLoader == sol::nil) - throw std::runtime_error("LuaUtil::I18nManager is not initialized"); - auto it = mContexts.find(contextName); - if (it != mContexts.end()) - return sol::make_object(mLua->sol(), it->second); - Context ctx{contextName, mLua->newTable(), call(mI18nLoader, "i18n.init")}; - ctx.updateLang(this); - mContexts.emplace(contextName, ctx); - return sol::make_object(mLua->sol(), ctx); - } - -} diff --git a/components/lua/i18n.hpp b/components/lua/i18n.hpp deleted file mode 100644 index 4bc7c624f1..0000000000 --- a/components/lua/i18n.hpp +++ /dev/null @@ -1,41 +0,0 @@ -#ifndef COMPONENTS_LUA_I18N_H -#define COMPONENTS_LUA_I18N_H - -#include "luastate.hpp" - -namespace LuaUtil -{ - - class I18nManager - { - public: - I18nManager(const VFS::Manager* vfs, LuaState* lua) : mVFS(vfs), mLua(lua) {} - void init(); - - void setPreferredLanguages(const std::vector& langs); - const std::vector& getPreferredLanguages() const { return mPreferredLanguages; } - - sol::object getContext(const std::string& contextName); - - private: - struct Context - { - std::string mName; - sol::table mLoadedLangs; - sol::table mI18n; - - void updateLang(I18nManager* manager); - void readLangData(I18nManager* manager, const std::string& lang); - sol::object translate(std::string_view key, const sol::object& data); - }; - - const VFS::Manager* mVFS; - LuaState* mLua; - sol::object mI18nLoader = sol::nil; - std::vector mPreferredLanguages; - std::map mContexts; - }; - -} - -#endif // COMPONENTS_LUA_I18N_H \ No newline at end of file diff --git a/components/lua/l10n.cpp b/components/lua/l10n.cpp new file mode 100644 index 0000000000..94d46f2522 --- /dev/null +++ b/components/lua/l10n.cpp @@ -0,0 +1,136 @@ +#include "l10n.hpp" + +#include + +#include + +namespace sol +{ + template <> + struct is_automagical : std::false_type {}; +} + +namespace LuaUtil +{ + void L10nManager::init() + { + sol::usertype ctx = mLua->sol().new_usertype("L10nContext"); + ctx[sol::meta_function::call] = &Context::translate; + } + + void L10nManager::setPreferredLocales(const std::vector& langs) + { + mPreferredLocales.clear(); + for (const auto &lang : langs) + mPreferredLocales.push_back(icu::Locale(lang.c_str())); + { + Log msg(Debug::Info); + msg << "Preferred locales:"; + for (const icu::Locale& l : mPreferredLocales) + msg << " " << l.getName(); + } + for (auto& [_, context] : mContexts) + context.updateLang(this); + } + + void L10nManager::Context::readLangData(L10nManager* manager, const icu::Locale& lang) + { + std::string path = "l10n/"; + path.append(mName); + path.append("/"); + path.append(lang.getName()); + path.append(".yaml"); + if (!manager->mVFS->exists(path)) + return; + + mMessageBundles->load(*manager->mVFS->get(path), lang, path); + } + + std::pair, std::vector> getICUArgs(std::string_view messageId, const sol::table &table) + { + std::vector args; + std::vector argNames; + for (auto elem : table) + for (auto& [key, value] : table) + { + // Argument values + if (value.is()) + args.push_back(icu::Formattable(value.as().c_str())); + // Note: While we pass all numbers as doubles, they still seem to be handled appropriately. + // Numbers can be forced to be integers using the argType number and argStyle integer + // E.g. {var, number, integer} + else if (value.is()) + args.push_back(icu::Formattable(value.as())); + else + { + Log(Debug::Error) << "Unrecognized argument type for key \"" << key.as() + << "\" when formatting message \"" << messageId << "\""; + } + + // Argument names + argNames.push_back(icu::UnicodeString::fromUTF8(key.as())); + } + return std::make_pair(args, argNames); + } + + std::string L10nManager::Context::translate(std::string_view key, const sol::object& data) + { + std::vector args; + std::vector argNames; + + if (data.is()) { + sol::table dataTable = data.as(); + auto argData = getICUArgs(key, dataTable); + args = argData.first; + argNames = argData.second; + } + + return mMessageBundles->formatMessage(key, argNames, args); + } + + void L10nManager::Context::updateLang(L10nManager* manager) + { + icu::Locale fallbackLocale = mMessageBundles->getFallbackLocale(); + mMessageBundles->setPreferredLocales(manager->mPreferredLocales); + int localeCount = 0; + bool fallbackLocaleInPreferred = false; + for (const icu::Locale& loc: mMessageBundles->getPreferredLocales()) + { + if (!mMessageBundles->isLoaded(loc)) + readLangData(manager, loc); + if (mMessageBundles->isLoaded(loc)) + { + localeCount++; + Log(Debug::Verbose) << "Language file \"l10n/" << mName << "/" << loc.getName() << ".yaml\" is enabled"; + if (loc == fallbackLocale) + fallbackLocaleInPreferred = true; + } + } + if (!mMessageBundles->isLoaded(fallbackLocale)) + readLangData(manager, fallbackLocale); + if (mMessageBundles->isLoaded(fallbackLocale) && !fallbackLocaleInPreferred) + Log(Debug::Verbose) << "Fallback language file \"l10n/" << mName << "/" << fallbackLocale.getName() << ".yaml\" is enabled"; + + if (localeCount == 0) + { + Log(Debug::Warning) << "No language files for the preferred languages found in \"l10n/" << mName << "\""; + } + } + + sol::object L10nManager::getContext(const std::string& contextName, const std::string& fallbackLocaleName) + { + auto it = mContexts.find(contextName); + if (it != mContexts.end()) + return sol::make_object(mLua->sol(), it->second); + icu::Locale fallbackLocale(fallbackLocaleName.c_str()); + Context ctx{contextName, std::make_shared(mPreferredLocales, fallbackLocale)}; + { + Log msg(Debug::Verbose); + msg << "Fallback locale: " << fallbackLocale.getName(); + } + ctx.updateLang(this); + mContexts.emplace(contextName, ctx); + return sol::make_object(mLua->sol(), ctx); + } + +} diff --git a/components/lua/l10n.hpp b/components/lua/l10n.hpp new file mode 100644 index 0000000000..04a3ad9c2c --- /dev/null +++ b/components/lua/l10n.hpp @@ -0,0 +1,42 @@ +#ifndef COMPONENTS_LUA_I18N_H +#define COMPONENTS_LUA_I18N_H + +#include "luastate.hpp" + +#include + +namespace LuaUtil +{ + + class L10nManager + { + public: + L10nManager(const VFS::Manager* vfs, LuaState* lua) : mVFS(vfs), mLua(lua) {} + void init(); + + void setPreferredLocales(const std::vector& locales); + const std::vector& getPreferredLocales() const { return mPreferredLocales; } + + sol::object getContext(const std::string& contextName, const std::string& fallbackLocale = "en"); + + private: + struct Context + { + const std::string mName; + // Must be a shared pointer so that sol::make_object copies the pointer, not the data structure. + std::shared_ptr mMessageBundles; + + void updateLang(L10nManager* manager); + void readLangData(L10nManager* manager, const icu::Locale& lang); + std::string translate(std::string_view key, const sol::object& data); + }; + + const VFS::Manager* mVFS; + LuaState* mLua; + std::vector mPreferredLocales; + std::map mContexts; + }; + +} + +#endif // COMPONENTS_LUA_I18N_H diff --git a/docs/source/reference/modding/settings/general.rst b/docs/source/reference/modding/settings/general.rst index ee5b908b4a..ae2448c38b 100644 --- a/docs/source/reference/modding/settings/general.rst +++ b/docs/source/reference/modding/settings/general.rst @@ -68,3 +68,21 @@ notify on saved screenshot :Default: False Show message box when screenshot is saved to a file. + +preferred locales +----------------- + +:Type: string +:Default: en + +List of the preferred locales separated by comma. +For example "de,en" means German as the first prority and English as a fallback. + +Each locale must consist of a two-letter language code (e.g. "de" or "en") and +can also optionally include a two-letter country code (e.g. "en_US", "fr_CA"). +Locales with country codes can match locales without one (e.g. specifying "en_US" +will match "en"), so is recommended that you include the country codes where possible, +since if the country code isn't specified the generic language-code only locale might +refer to any of the country-specific variants. + +This setting can only be configured by editing the settings configuration file. diff --git a/docs/source/reference/modding/settings/lua.rst b/docs/source/reference/modding/settings/lua.rst index 919d530d18..4433067952 100644 --- a/docs/source/reference/modding/settings/lua.rst +++ b/docs/source/reference/modding/settings/lua.rst @@ -26,15 +26,3 @@ If one, a separate thread is used. Values >1 are not yet supported. This setting can only be configured by editing the settings configuration file. - -i18n preferred languages ------------------------- - -:Type: string -:Default: en - -List of the preferred languages separated by comma. -For example "de,en" means German as the first prority and English as a fallback. - -This setting can only be configured by editing the settings configuration file. - diff --git a/extern/CMakeLists.txt b/extern/CMakeLists.txt index 5f4712ea3d..1c051bff61 100644 --- a/extern/CMakeLists.txt +++ b/extern/CMakeLists.txt @@ -226,3 +226,66 @@ if (BUILD_BENCHMARKS AND NOT OPENMW_USE_SYSTEM_BENCHMARK) ) FetchContent_MakeAvailableExcludeFromAll(benchmark) endif() + +if (NOT OPENMW_USE_SYSTEM_YAML_CPP) + if(YAML_CPP_STATIC) + set(YAML_BUILD_SHARED_LIBS OFF) + else() + set(YAML_BUILD_SHARED_LIBS ON) + endif() + + include(FetchContent) + FetchContent_Declare(yaml-cpp + URL https://github.com/jbeder/yaml-cpp/archive/refs/tags/yaml-cpp-0.7.0.zip + URL_HASH MD5=1e8ca0d6ccf99f3ed9506c1f6937d0ec + SOURCE_DIR fetched/yaml-cpp + ) + FetchContent_MakeAvailableExcludeFromAll(yaml-cpp) +endif() + +if (NOT OPENMW_USE_SYSTEM_ICU) + if (ANDROID) + # Note: Must be a build directory, not an install root, since the configure script + # looks for a configuration file which does not get installed. + set(OPENMW_ICU_HOST_BUILD_DIR "" CACHE STRING "A pre-built ICU build directory for the host system if cross-compiling") + # We need a host version of ICU so that the tools can be run when building the data library. + set(NDK_STANDARD_ROOT ${CMAKE_ANDROID_NDK}/toolchains/llvm/prebuilt/linux-x86_64) + string(REPLACE "android-" "" ANDROIDVER ${ANDROID_PLATFORM}) + set(ICU_ENV + "CC=ccache ${NDK_STANDARD_ROOT}/bin/aarch64-linux-android${ANDROIDVER}-clang" + "CXX=ccache ${NDK_STANDARD_ROOT}/bin/aarch64-linux-android${ANDROIDVER}-clang" + "RANLIB=${NDK_STANDARD_ROOT}/bin/aarch64-linux-android-ranlib" + "AR=${NDK_STANDARD_ROOT}/bin/aarch64-linux-android-ar" + "CPPFLAGS=${ANDROID_COMPILER_FLAGS}" + "LDFLAGS=${ANDROID_LINKER_FLAGS} -lc -lstdc++" + ) + # Wants a triple such as aarch64-linux-android, excluding a trailing + # -clang etc. + string(REGEX MATCH "^[^-]\+-[^-]+-[^-]+" ICU_TOOLCHAIN_NAME ${ANDROID_TOOLCHAIN_NAME}) + set(ICU_ADDITIONAL_OPTS --host=${ICU_TOOLCHAIN_NAME}${ANDROIDVER} --with-cross-build=${OPENMW_ICU_HOST_BUILD_DIR}) + endif() + include(ExternalProject) + ExternalProject_Add(icu + URL https://github.com/unicode-org/icu/archive/refs/tags/release-70-1.zip + URL_HASH MD5=49d5e2e5bab93ae1a4b56e916150544c + SOURCE_DIR fetched/icu + CONFIGURE_COMMAND ${CMAKE_COMMAND} -E env ${ICU_ENV} + /icu4c/source/configure --enable-static --disable-shared + --disable-tests --disable-samples --disable-icuio --disable-extras ${ICU_ADDITIONAL_OPTS} + BUILD_COMMAND make + INSTALL_COMMAND "" + ) + ExternalProject_Get_Property(icu SOURCE_DIR BINARY_DIR) + set(ICU_INCLUDE_DIRS + ${SOURCE_DIR}/icu4c/source/common + ${SOURCE_DIR}/icu4c/source/i18n + PARENT_SCOPE + ) + foreach(ICULIB data uc i18n) + add_library(ICU::${ICULIB} STATIC IMPORTED GLOBAL) + set_target_properties(ICU::${ICULIB} PROPERTIES IMPORTED_LOCATION + ${BINARY_DIR}/lib/${CMAKE_STATIC_LIBRARY_PREFIX}icu${ICULIB}${CMAKE_STATIC_LIBRARY_SUFFIX}) + add_dependencies(ICU::${ICULIB} icu) + endforeach() + set(ICU_LIBRARIES ICU::i18n ICU::uc ICU::data PARENT_SCOPE) +endif() diff --git a/extern/i18n.lua/CMakeLists.txt b/extern/i18n.lua/CMakeLists.txt deleted file mode 100644 index 1f7a71a2c2..0000000000 --- a/extern/i18n.lua/CMakeLists.txt +++ /dev/null @@ -1,17 +0,0 @@ -if (NOT DEFINED OPENMW_RESOURCES_ROOT) - return() -endif() - -# Copy resource files into the build directory -set(SDIR ${CMAKE_CURRENT_SOURCE_DIR}) -set(DDIRRELATIVE resources/lua_libs/i18n) - -set(I18N_LUA_FILES - i18n/init.lua - i18n/interpolate.lua - i18n/plural.lua - i18n/variants.lua - i18n/version.lua -) - -copy_all_resource_files(${CMAKE_CURRENT_SOURCE_DIR} ${OPENMW_RESOURCES_ROOT} ${DDIRRELATIVE} "${I18N_LUA_FILES}") diff --git a/extern/i18n.lua/LICENSE b/extern/i18n.lua/LICENSE deleted file mode 100644 index ddf484685b..0000000000 --- a/extern/i18n.lua/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License Terms -================= - -Copyright (c) 2012 Enrique García Cota. - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/extern/i18n.lua/README.md b/extern/i18n.lua/README.md deleted file mode 100644 index 8b3271c321..0000000000 --- a/extern/i18n.lua/README.md +++ /dev/null @@ -1,164 +0,0 @@ -i18n.lua -======== - -[![Build Status](https://travis-ci.org/kikito/i18n.lua.png?branch=master)](https://travis-ci.org/kikito/i18n.lua) - -A very complete i18n lib for Lua - -Description -=========== - -``` lua -i18n = require 'i18n' - --- loading stuff -i18n.set('en.welcome', 'welcome to this program') -i18n.load({ - en = { - good_bye = "good-bye!", - age_msg = "your age is %{age}.", - phone_msg = { - one = "you have one new message.", - other = "you have %{count} new messages." - } - } -}) -i18n.loadFile('path/to/your/project/i18n/de.lua') -- load German language file -i18n.loadFile('path/to/your/project/i18n/fr.lua') -- load French language file -… -- section 'using language files' below describes structure of files - --- setting the translation context -i18n.setLocale('en') -- English is the default locale anyway - --- getting translations -i18n.translate('welcome') -- Welcome to this program -i18n('welcome') -- Welcome to this program -i18n('age_msg', {age = 18}) -- Your age is 18. -i18n('phone_msg', {count = 1}) -- You have one new message. -i18n('phone_msg', {count = 2}) -- You have 2 new messages. -i18n('good_bye') -- Good-bye! - -``` - -Interpolation -============= - -You can interpolate variables in 3 different ways: - -``` lua --- the most usual one -i18n.set('variables', 'Interpolating variables: %{name} %{age}') -i18n('variables', {name='john', 'age'=10}) -- Interpolating variables: john 10 - -i18n.set('lua', 'Traditional Lua way: %d %s') -i18n('lua', {1, 'message'}) -- Traditional Lua way: 1 message - -i18n.set('combined', 'Combined: %.q %.d %.o') -i18n('combined', {name='john', 'age'=10}) -- Combined: john 10 12k -``` - - - -Pluralization -============= - -This lib implements the [unicode.org plural rules](http://cldr.unicode.org/index/cldr-spec/plural-rules). Just set the locale you want to use and it will deduce the appropiate pluralization rules: - -``` lua -i18n = require 'i18n' - -i18n.load({ - en = { - msg = { - one = "one message", - other = "%{count} messages" - } - }, - ru = { - msg = { - one = "1 сообщение", - few = "%{count} сообщения", - many = "%{count} сообщений", - other = "%{count} сообщения" - } - } -}) - -i18n('msg', {count = 1}) -- one message -i18n.setLocale('ru') -i18n('msg', {count = 5}) -- 5 сообщений -``` - -The appropiate rule is chosen by finding the 'root' of the locale used: for example if the current locale is 'fr-CA', the 'fr' rules will be applied. - -If the provided functions are not enough (i.e. invented languages) it's possible to specify a custom pluralization function in the second parameter of setLocale. This function must return 'one', 'few', 'other', etc given a number. - -Fallbacks -========= - -When a value is not found, the lib has several fallback mechanisms: - -* First, it will look in the current locale's parents. For example, if the locale was set to 'en-US' and the key 'msg' was not found there, it will be looked over in 'en'. -* Second, if the value is not found in the locale ancestry, a 'fallback locale' (by default: 'en') can be used. If the fallback locale has any parents, they will be looked over too. -* Third, if all the locales have failed, but there is a param called 'default' on the provided data, it will be used. -* Otherwise the translation will return nil. - -The parents of a locale are found by splitting the locale by its hyphens. Other separation characters (spaces, underscores, etc) are not supported. - -Using language files -==================== - -It might be a good idea to store each translation in a different file. This is supported via the 'i18n.loadFile' directive: - -``` lua -… -i18n.loadFile('path/to/your/project/i18n/de.lua') -- German translation -i18n.loadFile('path/to/your/project/i18n/en.lua') -- English translation -i18n.loadFile('path/to/your/project/i18n/fr.lua') -- French translation -… -``` - -The German language file 'de.lua' should read: - -``` lua -return { - de = { - good_bye = "Auf Wiedersehen!", - age_msg = "Ihr Alter beträgt %{age}.", - phone_msg = { - one = "Sie haben eine neue Nachricht.", - other = "Sie haben %{count} neue Nachrichten." - } - } -} -``` - -If desired, you can also store all translations in one single file (eg. 'translations.lua'): - -``` lua -return { - de = { - good_bye = "Auf Wiedersehen!", - age_msg = "Ihr Alter beträgt %{age}.", - phone_msg = { - one = "Sie haben eine neue Nachricht.", - other = "Sie haben %{count} neue Nachrichten." - } - }, - fr = { - good_bye = "Au revoir !", - age_msg = "Vous avez %{age} ans.", - phone_msg = { - one = "Vous avez une noveau message.", - other = "Vous avez %{count} noveaux messages." - } - }, - … -} -``` - -Specs -===== -This project uses [busted](https://github.com/Olivine-Labs/busted) for its specs. If you want to run the specs, you will have to install it first. Then just execute the following from the root inspect folder: - - busted diff --git a/extern/i18n.lua/i18n/init.lua b/extern/i18n.lua/i18n/init.lua deleted file mode 100644 index 6bcccd0572..0000000000 --- a/extern/i18n.lua/i18n/init.lua +++ /dev/null @@ -1,188 +0,0 @@ -local i18n = {} - -local store -local locale -local pluralizeFunction -local defaultLocale = 'en' -local fallbackLocale = defaultLocale - -local currentFilePath = (...):gsub("%.init$","") - -local plural = require(currentFilePath .. '.plural') -local interpolate = require(currentFilePath .. '.interpolate') -local variants = require(currentFilePath .. '.variants') -local version = require(currentFilePath .. '.version') - -i18n.plural, i18n.interpolate, i18n.variants, i18n.version, i18n._VERSION = - plural, interpolate, variants, version, version - --- private stuff - -local function dotSplit(str) - local fields, length = {},0 - str:gsub("[^%.]+", function(c) - length = length + 1 - fields[length] = c - end) - return fields, length -end - -local function isPluralTable(t) - return type(t) == 'table' and type(t.other) == 'string' -end - -local function isPresent(str) - return type(str) == 'string' and #str > 0 -end - -local function assertPresent(functionName, paramName, value) - if isPresent(value) then return end - - local msg = "i18n.%s requires a non-empty string on its %s. Got %s (a %s value)." - error(msg:format(functionName, paramName, tostring(value), type(value))) -end - -local function assertPresentOrPlural(functionName, paramName, value) - if isPresent(value) or isPluralTable(value) then return end - - local msg = "i18n.%s requires a non-empty string or plural-form table on its %s. Got %s (a %s value)." - error(msg:format(functionName, paramName, tostring(value), type(value))) -end - -local function assertPresentOrTable(functionName, paramName, value) - if isPresent(value) or type(value) == 'table' then return end - - local msg = "i18n.%s requires a non-empty string or table on its %s. Got %s (a %s value)." - error(msg:format(functionName, paramName, tostring(value), type(value))) -end - -local function assertFunctionOrNil(functionName, paramName, value) - if value == nil or type(value) == 'function' then return end - - local msg = "i18n.%s requires a function (or nil) on param %s. Got %s (a %s value)." - error(msg:format(functionName, paramName, tostring(value), type(value))) -end - -local function defaultPluralizeFunction(count) - return plural.get(variants.root(i18n.getLocale()), count) -end - -local function pluralize(t, data) - assertPresentOrPlural('interpolatePluralTable', 't', t) - data = data or {} - local count = data.count or 1 - local plural_form = pluralizeFunction(count) - return t[plural_form] -end - -local function treatNode(node, data) - if type(node) == 'string' then - return interpolate(node, data) - elseif isPluralTable(node) then - return interpolate(pluralize(node, data), data) - end - return node -end - -local function recursiveLoad(currentContext, data) - local composedKey - for k,v in pairs(data) do - composedKey = (currentContext and (currentContext .. '.') or "") .. tostring(k) - assertPresent('load', composedKey, k) - assertPresentOrTable('load', composedKey, v) - if type(v) == 'string' then - i18n.set(composedKey, v) - else - recursiveLoad(composedKey, v) - end - end -end - -local function localizedTranslate(key, loc, data) - local path, length = dotSplit(loc .. "." .. key) - local node = store - - for i=1, length do - node = node[path[i]] - if not node then return nil end - end - - return treatNode(node, data) -end - --- public interface - -function i18n.set(key, value) - assertPresent('set', 'key', key) - assertPresentOrPlural('set', 'value', value) - - local path, length = dotSplit(key) - local node = store - - for i=1, length-1 do - key = path[i] - node[key] = node[key] or {} - node = node[key] - end - - local lastKey = path[length] - node[lastKey] = value -end - -function i18n.translate(key, data) - assertPresent('translate', 'key', key) - - data = data or {} - local usedLocale = data.locale or locale - - local fallbacks = variants.fallbacks(usedLocale, fallbackLocale) - for i=1, #fallbacks do - local value = localizedTranslate(key, fallbacks[i], data) - if value then return value end - end - - return data.default -end - -function i18n.setLocale(newLocale, newPluralizeFunction) - assertPresent('setLocale', 'newLocale', newLocale) - assertFunctionOrNil('setLocale', 'newPluralizeFunction', newPluralizeFunction) - locale = newLocale - pluralizeFunction = newPluralizeFunction or defaultPluralizeFunction -end - -function i18n.setFallbackLocale(newFallbackLocale) - assertPresent('setFallbackLocale', 'newFallbackLocale', newFallbackLocale) - fallbackLocale = newFallbackLocale -end - -function i18n.getFallbackLocale() - return fallbackLocale -end - -function i18n.getLocale() - return locale -end - -function i18n.reset() - store = {} - plural.reset() - i18n.setLocale(defaultLocale) - i18n.setFallbackLocale(defaultLocale) -end - -function i18n.load(data) - recursiveLoad(nil, data) -end - -function i18n.loadFile(path) - local chunk = assert(loadfile(path)) - local data = chunk() - i18n.load(data) -end - -setmetatable(i18n, {__call = function(_, ...) return i18n.translate(...) end}) - -i18n.reset() - -return i18n diff --git a/extern/i18n.lua/i18n/interpolate.lua b/extern/i18n.lua/i18n/interpolate.lua deleted file mode 100644 index c6bb242f05..0000000000 --- a/extern/i18n.lua/i18n/interpolate.lua +++ /dev/null @@ -1,60 +0,0 @@ -local unpack = unpack or table.unpack -- lua 5.2 compat - -local FORMAT_CHARS = { c=1, d=1, E=1, e=1, f=1, g=1, G=1, i=1, o=1, u=1, X=1, x=1, s=1, q=1, ['%']=1 } - --- matches a string of type %{age} -local function interpolateValue(string, variables) - return string:gsub("(.?)%%{%s*(.-)%s*}", - function (previous, key) - if previous == "%" then - return - else - return previous .. tostring(variables[key]) - end - end) -end - --- matches a string of type %.d -local function interpolateField(string, variables) - return string:gsub("(.?)%%<%s*(.-)%s*>%.([cdEefgGiouXxsq])", - function (previous, key, format) - if previous == "%" then - return - else - return previous .. string.format("%" .. format, variables[key] or "nil") - end - end) -end - -local function escapePercentages(string) - return string:gsub("(%%)(.?)", function(_, char) - if FORMAT_CHARS[char] then - return "%" .. char - else - return "%%" .. char - end - end) -end - -local function unescapePercentages(string) - return string:gsub("(%%%%)(.?)", function(_, char) - if FORMAT_CHARS[char] then - return "%" .. char - else - return "%%" .. char - end - end) -end - -local function interpolate(pattern, variables) - variables = variables or {} - local result = pattern - result = interpolateValue(result, variables) - result = interpolateField(result, variables) - result = escapePercentages(result) - result = string.format(result, unpack(variables)) - result = unescapePercentages(result) - return result -end - -return interpolate diff --git a/extern/i18n.lua/i18n/plural.lua b/extern/i18n.lua/i18n/plural.lua deleted file mode 100644 index bb99804ee8..0000000000 --- a/extern/i18n.lua/i18n/plural.lua +++ /dev/null @@ -1,280 +0,0 @@ -local plural = {} -local defaultFunction = nil --- helper functions - -local function assertPresentString(functionName, paramName, value) - if type(value) ~= 'string' or #value == 0 then - local msg = "Expected param %s of function %s to be a string, but got %s (a value of type %s) instead" - error(msg:format(paramName, functionName, tostring(value), type(value))) - end -end - -local function assertNumber(functionName, paramName, value) - if type(value) ~= 'number' then - local msg = "Expected param %s of function %s to be a number, but got %s (a value of type %s) instead" - error(msg:format(paramName, functionName, tostring(value), type(value))) - end -end - --- transforms "foo bar baz" into {'foo','bar','baz'} -local function words(str) - local result, length = {}, 0 - str:gsub("%S+", function(word) - length = length + 1 - result[length] = word - end) - return result -end - -local function isInteger(n) - return n == math.floor(n) -end - -local function between(value, min, max) - return value >= min and value <= max -end - -local function inside(v, list) - for i=1, #list do - if v == list[i] then return true end - end - return false -end - - --- pluralization functions - -local pluralization = {} - -local f1 = function(n) - return n == 1 and "one" or "other" -end -pluralization[f1] = words([[ - af asa bem bez bg bn brx ca cgg chr da de dv ee el - en eo es et eu fi fo fur fy gl gsw gu ha haw he is - it jmc kaj kcg kk kl ksb ku lb lg mas ml mn mr nah - nb nd ne nl nn no nr ny nyn om or pa pap ps pt rm - rof rwk saq seh sn so sq ss ssy st sv sw syr ta te - teo tig tk tn ts ur ve vun wae xh xog zu -]]) - -local f2 = function(n) - return (n == 0 or n == 1) and "one" or "other" -end -pluralization[f2] = words("ak am bh fil guw hi ln mg nso ti tl wa") - -local f3 = function(n) - if not isInteger(n) then return 'other' end - return (n == 0 and "zero") or - (n == 1 and "one") or - (n == 2 and "two") or - (between(n % 100, 3, 10) and "few") or - (between(n % 100, 11, 99) and "many") or - "other" -end -pluralization[f3] = {'ar'} - -local f4 = function() - return "other" -end -pluralization[f4] = words([[ - az bm bo dz fa hu id ig ii ja jv ka kde kea km kn - ko lo ms my root sah ses sg th to tr vi wo yo zh -]]) - -local f5 = function(n) - if not isInteger(n) then return 'other' end - local n_10, n_100 = n % 10, n % 100 - return (n_10 == 1 and n_100 ~= 11 and 'one') or - (between(n_10, 2, 4) and not between(n_100, 12, 14) and 'few') or - ((n_10 == 0 or between(n_10, 5, 9) or between(n_100, 11, 14)) and 'many') or - 'other' -end -pluralization[f5] = words('be bs hr ru sh sr uk') - -local f6 = function(n) - if not isInteger(n) then return 'other' end - local n_10, n_100 = n % 10, n % 100 - return (n_10 == 1 and not inside(n_100, {11,71,91}) and 'one') or - (n_10 == 2 and not inside(n_100, {12,72,92}) and 'two') or - (inside(n_10, {3,4,9}) and - not between(n_100, 10, 19) and - not between(n_100, 70, 79) and - not between(n_100, 90, 99) - and 'few') or - (n ~= 0 and n % 1000000 == 0 and 'many') or - 'other' -end -pluralization[f6] = {'br'} - -local f7 = function(n) - return (n == 1 and 'one') or - ((n == 2 or n == 3 or n == 4) and 'few') or - 'other' -end -pluralization[f7] = {'cz', 'sk'} - -local f8 = function(n) - return (n == 0 and 'zero') or - (n == 1 and 'one') or - (n == 2 and 'two') or - (n == 3 and 'few') or - (n == 6 and 'many') or - 'other' -end -pluralization[f8] = {'cy'} - -local f9 = function(n) - return (n >= 0 and n < 2 and 'one') or - 'other' -end -pluralization[f9] = {'ff', 'fr', 'kab'} - -local f10 = function(n) - return (n == 1 and 'one') or - (n == 2 and 'two') or - ((n == 3 or n == 4 or n == 5 or n == 6) and 'few') or - ((n == 7 or n == 8 or n == 9 or n == 10) and 'many') or - 'other' -end -pluralization[f10] = {'ga'} - -local f11 = function(n) - return ((n == 1 or n == 11) and 'one') or - ((n == 2 or n == 12) and 'two') or - (isInteger(n) and (between(n, 3, 10) or between(n, 13, 19)) and 'few') or - 'other' -end -pluralization[f11] = {'gd'} - -local f12 = function(n) - local n_10 = n % 10 - return ((n_10 == 1 or n_10 == 2 or n % 20 == 0) and 'one') or - 'other' -end -pluralization[f12] = {'gv'} - -local f13 = function(n) - return (n == 1 and 'one') or - (n == 2 and 'two') or - 'other' -end -pluralization[f13] = words('iu kw naq se sma smi smj smn sms') - -local f14 = function(n) - return (n == 0 and 'zero') or - (n == 1 and 'one') or - 'other' -end -pluralization[f14] = {'ksh'} - -local f15 = function(n) - return (n == 0 and 'zero') or - (n > 0 and n < 2 and 'one') or - 'other' -end -pluralization[f15] = {'lag'} - -local f16 = function(n) - if not isInteger(n) then return 'other' end - if between(n % 100, 11, 19) then return 'other' end - local n_10 = n % 10 - return (n_10 == 1 and 'one') or - (between(n_10, 2, 9) and 'few') or - 'other' -end -pluralization[f16] = {'lt'} - -local f17 = function(n) - return (n == 0 and 'zero') or - ((n % 10 == 1 and n % 100 ~= 11) and 'one') or - 'other' -end -pluralization[f17] = {'lv'} - -local f18 = function(n) - return((n % 10 == 1 and n ~= 11) and 'one') or - 'other' -end -pluralization[f18] = {'mk'} - -local f19 = function(n) - return (n == 1 and 'one') or - ((n == 0 or - (n ~= 1 and isInteger(n) and between(n % 100, 1, 19))) - and 'few') or - 'other' -end -pluralization[f19] = {'mo', 'ro'} - -local f20 = function(n) - if n == 1 then return 'one' end - if not isInteger(n) then return 'other' end - local n_100 = n % 100 - return ((n == 0 or between(n_100, 2, 10)) and 'few') or - (between(n_100, 11, 19) and 'many') or - 'other' -end -pluralization[f20] = {'mt'} - -local f21 = function(n) - if n == 1 then return 'one' end - if not isInteger(n) then return 'other' end - local n_10, n_100 = n % 10, n % 100 - - return ((between(n_10, 2, 4) and not between(n_100, 12, 14)) and 'few') or - ((n_10 == 0 or n_10 == 1 or between(n_10, 5, 9) or between(n_100, 12, 14)) and 'many') or - 'other' -end -pluralization[f21] = {'pl'} - -local f22 = function(n) - return (n == 0 or n == 1) and 'one' or - 'other' -end -pluralization[f22] = {'shi'} - -local f23 = function(n) - local n_100 = n % 100 - return (n_100 == 1 and 'one') or - (n_100 == 2 and 'two') or - ((n_100 == 3 or n_100 == 4) and 'few') or - 'other' -end -pluralization[f23] = {'sl'} - -local f24 = function(n) - return (isInteger(n) and (n == 0 or n == 1 or between(n, 11, 99)) and 'one') - or 'other' -end -pluralization[f24] = {'tzm'} - -local pluralizationFunctions = {} -for f,locales in pairs(pluralization) do - for _,locale in ipairs(locales) do - pluralizationFunctions[locale] = f - end -end - --- public interface - -function plural.get(locale, n) - assertPresentString('i18n.plural.get', 'locale', locale) - assertNumber('i18n.plural.get', 'n', n) - - local f = pluralizationFunctions[locale] or defaultFunction - - return f(math.abs(n)) -end - -function plural.setDefaultFunction(f) - defaultFunction = f -end - -function plural.reset() - defaultFunction = pluralizationFunctions['en'] -end - -plural.reset() - -return plural diff --git a/extern/i18n.lua/i18n/variants.lua b/extern/i18n.lua/i18n/variants.lua deleted file mode 100644 index 0cfad42f6c..0000000000 --- a/extern/i18n.lua/i18n/variants.lua +++ /dev/null @@ -1,49 +0,0 @@ -local variants = {} - -local function reverse(arr, length) - local result = {} - for i=1, length do result[i] = arr[length-i+1] end - return result, length -end - -local function concat(arr1, len1, arr2, len2) - for i = 1, len2 do - arr1[len1 + i] = arr2[i] - end - return arr1, len1 + len2 -end - -function variants.ancestry(locale) - local result, length, accum = {},0,nil - locale:gsub("[^%-]+", function(c) - length = length + 1 - accum = accum and (accum .. '-' .. c) or c - result[length] = accum - end) - return reverse(result, length) -end - -function variants.isParent(parent, child) - return not not child:match("^".. parent .. "%-") -end - -function variants.root(locale) - return locale:match("[^%-]+") -end - -function variants.fallbacks(locale, fallbackLocale) - if locale == fallbackLocale or - variants.isParent(fallbackLocale, locale) then - return variants.ancestry(locale) - end - if variants.isParent(locale, fallbackLocale) then - return variants.ancestry(fallbackLocale) - end - - local ancestry1, length1 = variants.ancestry(locale) - local ancestry2, length2 = variants.ancestry(fallbackLocale) - - return concat(ancestry1, length1, ancestry2, length2) -end - -return variants diff --git a/extern/i18n.lua/i18n/version.lua b/extern/i18n.lua/i18n/version.lua deleted file mode 100644 index eb788884ac..0000000000 --- a/extern/i18n.lua/i18n/version.lua +++ /dev/null @@ -1 +0,0 @@ -return '0.9.2' diff --git a/files/CMakeLists.txt b/files/CMakeLists.txt index 607ddeca49..cea33f0f40 100644 --- a/files/CMakeLists.txt +++ b/files/CMakeLists.txt @@ -3,4 +3,3 @@ add_subdirectory(shaders) add_subdirectory(vfs) add_subdirectory(builtin_scripts) add_subdirectory(lua_api) -add_subdirectory(../extern/i18n.lua ${CMAKE_CURRENT_BINARY_DIR}/files) diff --git a/files/builtin_scripts/CMakeLists.txt b/files/builtin_scripts/CMakeLists.txt index 4e1c08ee8d..7afcd7f529 100644 --- a/files/builtin_scripts/CMakeLists.txt +++ b/files/builtin_scripts/CMakeLists.txt @@ -14,7 +14,7 @@ set(LUA_BUILTIN_FILES scripts/omw/head_bobbing.lua scripts/omw/third_person.lua - i18n/Calendar/en.lua + l10n/Calendar/en.yaml ) foreach (f ${LUA_BUILTIN_FILES}) diff --git a/files/builtin_scripts/i18n/Calendar/en.lua b/files/builtin_scripts/i18n/Calendar/en.lua deleted file mode 100644 index a4e8183a92..0000000000 --- a/files/builtin_scripts/i18n/Calendar/en.lua +++ /dev/null @@ -1,42 +0,0 @@ --- source: https://en.uesp.net/wiki/Lore:Calendar - -return { - month1 = "Morning Star", - month2 = "Sun's Dawn", - month3 = "First Seed", - month4 = "Rain's Hand", - month5 = "Second Seed", - month6 = "Midyear", - month7 = "Sun's Height", - month8 = "Last Seed", - month9 = "Hearthfire", - month10 = "Frostfall", - month11 = "Sun's Dusk", - month12 = "Evening Star", - - -- The variant of month names in the context "day X of month Y". - -- In English it is the same, but some languages require a different form. - monthInGenitive1 = "Morning Star", - monthInGenitive2 = "Sun's Dawn", - monthInGenitive3 = "First Seed", - monthInGenitive4 = "Rain's Hand", - monthInGenitive5 = "Second Seed", - monthInGenitive6 = "Midyear", - monthInGenitive7 = "Sun's Height", - monthInGenitive8 = "Last Seed", - monthInGenitive9 = "Hearthfire", - monthInGenitive10 = "Frostfall", - monthInGenitive11 = "Sun's Dusk", - monthInGenitive12 = "Evening Star", - - dateFormat = "day %{day} of %{monthInGenitive} %{year}", - - weekday1 = "Sundas", - weekday2 = "Morndas", - weekday3 = "Tirdas", - weekday4 = "Middas", - weekday5 = "Turdas", - weekday6 = "Fredas", - weekday7 = "Loredas", -} - diff --git a/files/builtin_scripts/l10n/Calendar/en.yaml b/files/builtin_scripts/l10n/Calendar/en.yaml new file mode 100644 index 0000000000..0b009f4c57 --- /dev/null +++ b/files/builtin_scripts/l10n/Calendar/en.yaml @@ -0,0 +1,39 @@ +# source: https://en.uesp.net/wiki/Lore:Calendar + +month1: "Morning Star" +month2: "Sun's Dawn" +month3: "First Seed" +month4: "Rain's Hand" +month5: "Second Seed" +month6: "Midyear" +month7: "Sun's Height" +month8: "Last Seed" +month9: "Hearthfire" +month10: "Frostfall" +month11: "Sun's Dusk" +month12: "Evening Star" + +# The variant of month names in the context "day X of month Y". +# In English it is the same, but some languages require a different form. +monthInGenitive1: "Morning Star" +monthInGenitive2: "Sun's Dawn" +monthInGenitive3: "First Seed" +monthInGenitive4: "Rain's Hand" +monthInGenitive5: "Second Seed" +monthInGenitive6: "Midyear" +monthInGenitive7: "Sun's Height" +monthInGenitive8: "Last Seed" +monthInGenitive9: "Hearthfire" +monthInGenitive10: "Frostfall" +monthInGenitive11: "Sun's Dusk" +monthInGenitive12: "Evening Star" + +dateFormat: "day {day} of {monthInGenitive} {year}" + +weekday1: "Sundas" +weekday2: "Morndas" +weekday3: "Tirdas" +weekday4: "Middas" +weekday5: "Turdas" +weekday6: "Fredas" +weekday7: "Loredas" diff --git a/files/builtin_scripts/openmw_aux/calendar.lua b/files/builtin_scripts/openmw_aux/calendar.lua index 181b133b83..85b9db8e49 100644 --- a/files/builtin_scripts/openmw_aux/calendar.lua +++ b/files/builtin_scripts/openmw_aux/calendar.lua @@ -6,7 +6,7 @@ local core = require('openmw.core') local time = require('openmw_aux.time') -local i18n = core.i18n('Calendar') +local l10n = core.l10n('Calendar') local monthsDuration = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} local daysInWeek = 7 @@ -31,10 +31,10 @@ local function gameTime(t) end local function defaultDateFormat(t) - return i18n('dateFormat', { + return l10n('dateFormat', { day = t.day, - month = i18n('month' .. t.month), - monthInGenitive = i18n('monthInGenitive' .. t.month), + month = l10n('month' .. t.month), + monthInGenitive = l10n('monthInGenitive' .. t.month), year = t.year, }) end @@ -63,8 +63,8 @@ local function formatGameTime(formatStr, timestamp) if formatStr == '*t' then return t end local replFn = function(tag) - if tag == '%a' or tag == '%A' then return i18n('weekday' .. t.wday) end - if tag == '%b' or tag == '%B' then return i18n('monthInGenitive' .. t.month) end + if tag == '%a' or tag == '%A' then return l10n('weekday' .. t.wday) end + if tag == '%b' or tag == '%B' then return l10n('monthInGenitive' .. t.month) end if tag == '%c' then return string.format('%02d:%02d %s', t.hour, t.min, defaultDateFormat(t)) end @@ -137,7 +137,7 @@ return { -- @param monthIndex -- @return #string monthName = function(m) - return i18n('month' .. ((m-1) % #monthsDuration + 1)) + return l10n('month' .. ((m-1) % #monthsDuration + 1)) end, --- The name of a month in genitive (for English is the same as `monthName`, but in some languages the form can differ). @@ -145,7 +145,7 @@ return { -- @param monthIndex -- @return #string monthNameInGenitive = function(m) - return i18n('monthInGenitive' .. ((m-1) % #monthsDuration + 1)) + return l10n('monthInGenitive' .. ((m-1) % #monthsDuration + 1)) end, --- The name of a weekday @@ -153,7 +153,7 @@ return { -- @param dayIndex -- @return #string weekdayName = function(d) - return i18n('weekday' .. ((d-1) % daysInWeek + 1)) + return l10n('weekday' .. ((d-1) % daysInWeek + 1)) end, } diff --git a/files/lua_api/openmw/core.lua b/files/lua_api/openmw/core.lua index 43b2d29a70..b1a10b9cfb 100644 --- a/files/lua_api/openmw/core.lua +++ b/files/lua_api/openmw/core.lua @@ -53,37 +53,71 @@ -- @return #any --- --- Return i18n formatting function for the given context. --- It is based on `i18n.lua` library. --- Language files should be stored in VFS as `i18n//.lua`. --- See https://github.com/kikito/i18n.lua for format details. --- @function [parent=#core] i18n --- @param #string context I18n context; recommended to use the name of the mod. +-- Return l10n formatting function for the given context. +-- Language files should be stored in VFS as `l10n//.yaml`. +-- +-- Locales usually have the form {lang}_{COUNTRY}, +-- where {lang} is a lowercase two-letter language code and {COUNTRY} is an uppercase +-- two-letter country code. Capitalization and the separator must have exactly +-- this format for language files to be recognized, but when users request a +-- locale they do not need to match capitalization and can use hyphens instead of +-- underscores. +-- +-- Locales may also contain variants and keywords. See https://unicode-org.github.io/icu/userguide/locale/#language-code +-- for full details. +-- +-- Messages have the form of ICU MessageFormat strings. +-- See https://unicode-org.github.io/icu/userguide/format_parse/messages/ +-- for a guide to MessageFormat, and see +-- https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1MessageFormat.html +-- for full details of the MessageFormat syntax. +-- @function [parent=#core] l10n +-- @param #string context l10n context; recommended to use the name of the mod. +-- @param #string fallbackLocale The source locale containing the default messages +-- If omitted defaults to "en" -- @return #function -- @usage --- -- DataFiles/i18n/MyMod/en.lua --- return { --- good_morning = 'Good morning.', --- you_have_arrows = { --- one = 'You have one arrow.', --- other = 'You have %{count} arrows.', --- }, --- } +-- # DataFiles/l10n/MyMod/en.yaml +-- good_morning: 'Good morning.' +-- +-- you_have_arrows: |- {count, plural, +-- one {You have one arrow.} +-- other {You have {count} arrows.} +-- } -- @usage --- -- DataFiles/i18n/MyMod/de.lua --- return { --- good_morning = "Guten Morgen.", --- you_have_arrows = { --- one = "Du hast ein Pfeil.", --- other = "Du hast %{count} Pfeile.", --- }, --- ["Hello %{name}!"] = "Hallo %{name}!", --- } +-- # DataFiles/l10n/MyMod/de.yaml +-- good_morning: "Guten Morgen." +-- you_have_arrows: |- {count, plural, +-- one {Du hast ein Pfeil.} +-- other {Du hast {count} Pfeile.} +-- } +-- "Hello {name}!": "Hallo {name}!" -- @usage --- local myMsg = core.i18n('MyMod') +-- # DataFiles/l10n/AdvancedExample/en.yaml +-- # More complicated patterns +-- # select rules can be used to match arbitrary string arguments +-- # The default keyword other must always be provided +-- pc_must_come: {PCGender, select, +-- male {He is} +-- female {She is} +-- other {They are} +-- } coming with us. +-- # Numbers have various formatting options +-- quest_completion: "The quest is {done, number, percent} complete.", +-- # E.g. "You came in 4th place" +-- ordinal: "You came in {num, ordinal} place." +-- # E.g. "There is one thing", "There are one hundred things" +-- spellout: "There {num, plural, one{is {num, spellout} thing} other{are {num, spellout} things}}." +-- numbers: "{int} and {double, number, integer} are integers, but {double} is a double" +-- # Numbers can be formatted with custom patterns +-- # See https://unicode-org.github.io/icu/userguide/format_parse/numbers/skeletons.html#syntax +-- rounding: "{value, number, :: .00}" +-- @usage +-- -- Usage in Lua +-- local myMsg = core.l10n('MyMod', 'en') -- print( myMsg('good_morning') ) -- print( myMsg('you_have_arrows', {count=5}) ) --- print( myMsg('Hello %{name}!', {name='World'}) ) +-- print( myMsg('Hello {name}!', {name='World'}) ) --- diff --git a/files/settings-default.cfg b/files/settings-default.cfg index ecf5e1ec40..50214668d1 100644 --- a/files/settings-default.cfg +++ b/files/settings-default.cfg @@ -396,6 +396,10 @@ texture mipmap = nearest # Show message box when screenshot is saved to a file. notify on saved screenshot = false +# List of the preferred languages separated by comma. +# For example "de,en" means German as the first prority and English as a fallback. +preferred locales = en + [Shaders] # Force rendering with shaders. By default, only bump-mapped objects will use shaders. @@ -1125,8 +1129,3 @@ lua debug = false # Set the maximum number of threads used for Lua scripts. # If zero, Lua scripts are processed in the main thread. lua num threads = 1 - -# List of the preferred languages separated by comma. -# For example "de,en" means German as the first prority and English as a fallback. -i18n preferred languages = en -