summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpsi29a <psi29a@gmail.com>2022-01-03 09:14:45 +0000
committerpsi29a <psi29a@gmail.com>2022-01-03 09:14:45 +0000
commitb0e282034055a783b83ecef6837957678ef2d6af (patch)
tree16fb7db16439a126e3bc7dbb70a7a7232f6ae790
parent23e279c23eb752c311db28a019fc4030b8f5e80a (diff)
parent0f246e73653fb1790c0a7c3cdf61e2197bbbade4 (diff)
Merge branch 'lua_i18n' into 'master'
Lua i18n Closes #6504 See merge request OpenMW/openmw!1520
-rw-r--r--apps/openmw/engine.cpp2
-rw-r--r--apps/openmw/mwlua/context.hpp2
-rw-r--r--apps/openmw/mwlua/luabindings.cpp4
-rw-r--r--apps/openmw/mwlua/luamanagerimp.cpp11
-rw-r--r--apps/openmw/mwlua/luamanagerimp.hpp4
-rw-r--r--apps/openmw_test_suite/CMakeLists.txt1
-rw-r--r--apps/openmw_test_suite/lua/test_i18n.cpp110
-rw-r--r--apps/openmw_test_suite/lua/test_lua.cpp4
-rw-r--r--apps/openmw_test_suite/lua/test_utilpackage.cpp6
-rw-r--r--apps/openmw_test_suite/lua/testing_util.hpp7
-rw-r--r--components/CMakeLists.txt4
-rw-r--r--components/lua/i18n.cpp108
-rw-r--r--components/lua/i18n.hpp41
-rw-r--r--components/lua/luastate.cpp81
-rw-r--r--components/lua/luastate.hpp9
-rw-r--r--docs/source/reference/modding/settings/lua.rst11
-rw-r--r--extern/i18n.lua/CMakeLists.txt17
-rw-r--r--extern/i18n.lua/LICENSE22
-rw-r--r--extern/i18n.lua/README.md164
-rw-r--r--extern/i18n.lua/i18n/init.lua188
-rw-r--r--extern/i18n.lua/i18n/interpolate.lua60
-rw-r--r--extern/i18n.lua/i18n/plural.lua280
-rw-r--r--extern/i18n.lua/i18n/variants.lua49
-rw-r--r--extern/i18n.lua/i18n/version.lua1
-rw-r--r--files/CMakeLists.txt1
-rw-r--r--files/lua_api/openmw/core.lua33
-rw-r--r--files/settings-default.cfg4
27 files changed, 1198 insertions, 26 deletions
diff --git a/apps/openmw/engine.cpp b/apps/openmw/engine.cpp
index 1a73ae3531..afba76edce 100644
--- a/apps/openmw/engine.cpp
+++ b/apps/openmw/engine.cpp
@@ -737,7 +737,7 @@ void OMW::Engine::prepareEngine (Settings::Manager & settings)
mViewer->addEventHandler(mScreenCaptureHandler);
- mLuaManager = new MWLua::LuaManager(mVFS.get());
+ mLuaManager = new MWLua::LuaManager(mVFS.get(), (mResDir / "lua_libs").string());
mEnvironment.setLuaManager(mLuaManager);
// Create input and UI first to set up a bootstrapping environment for
diff --git a/apps/openmw/mwlua/context.hpp b/apps/openmw/mwlua/context.hpp
index b3e3703a46..7ff584d8cc 100644
--- a/apps/openmw/mwlua/context.hpp
+++ b/apps/openmw/mwlua/context.hpp
@@ -7,6 +7,7 @@ namespace LuaUtil
{
class LuaState;
class UserdataSerializer;
+ class I18nManager;
}
namespace MWLua
@@ -20,6 +21,7 @@ namespace MWLua
LuaManager* mLuaManager;
LuaUtil::LuaState* mLua;
LuaUtil::UserdataSerializer* mSerializer;
+ LuaUtil::I18nManager* mI18n;
WorldView* mWorldView;
LocalEventQueue* mLocalEventQueue;
GlobalEventQueue* mGlobalEventQueue;
diff --git a/apps/openmw/mwlua/luabindings.cpp b/apps/openmw/mwlua/luabindings.cpp
index 57b1b17a3b..c525fd8a23 100644
--- a/apps/openmw/mwlua/luabindings.cpp
+++ b/apps/openmw/mwlua/luabindings.cpp
@@ -1,6 +1,7 @@
#include "luabindings.hpp"
#include <components/lua/luastate.hpp>
+#include <components/lua/i18n.hpp>
#include <components/queries/luabindings.hpp>
#include "../mwbase/environment.hpp"
@@ -25,7 +26,7 @@ namespace MWLua
{
auto* lua = context.mLua;
sol::table api(lua->sol(), sol::create);
- api["API_REVISION"] = 11;
+ api["API_REVISION"] = 12;
api["quit"] = [lua]()
{
Log(Debug::Warning) << "Quit requested by a Lua script.\n" << lua->debugTraceback();
@@ -64,6 +65,7 @@ namespace MWLua
{"CarriedLeft", MWWorld::InventoryStore::Slot_CarriedLeft},
{"Ammunition", MWWorld::InventoryStore::Slot_Ammunition}
}));
+ api["i18n"] = [i18n=context.mI18n](const std::string& context) { return i18n->getContext(context); };
return LuaUtil::makeReadOnly(api);
}
diff --git a/apps/openmw/mwlua/luamanagerimp.cpp b/apps/openmw/mwlua/luamanagerimp.cpp
index f134ef86dd..be5764c32f 100644
--- a/apps/openmw/mwlua/luamanagerimp.cpp
+++ b/apps/openmw/mwlua/luamanagerimp.cpp
@@ -6,6 +6,8 @@
#include <components/esm/esmwriter.hpp>
#include <components/esm/luascripts.hpp>
+#include <components/settings/settings.hpp>
+
#include <components/lua/utilpackage.hpp>
#include "../mwbase/windowmanager.hpp"
@@ -20,9 +22,10 @@
namespace MWLua
{
- LuaManager::LuaManager(const VFS::Manager* vfs) : mLua(vfs, &mConfiguration)
+ LuaManager::LuaManager(const VFS::Manager* vfs, const std::string& libsDir) : mLua(vfs, &mConfiguration), mI18n(vfs, &mLua)
{
Log(Debug::Info) << "Lua version: " << LuaUtil::getLuaVersion();
+ mLua.addInternalLibSearchPath(libsDir);
mGlobalSerializer = createUserdataSerializer(false, mWorldView.getObjectRegistry());
mLocalSerializer = createUserdataSerializer(true, mWorldView.getObjectRegistry());
@@ -46,6 +49,7 @@ namespace MWLua
context.mIsGlobal = true;
context.mLuaManager = this;
context.mLua = &mLua;
+ context.mI18n = &mI18n;
context.mWorldView = &mWorldView;
context.mLocalEventQueue = &mLocalEvents;
context.mGlobalEventQueue = &mGlobalEvents;
@@ -55,6 +59,11 @@ namespace MWLua
localContext.mIsGlobal = false;
localContext.mSerializer = mLocalSerializer.get();
+ mI18n.init();
+ std::vector<std::string> preferredLanguages;
+ Misc::StringUtils::split(Settings::Manager::getString("i18n preferred languages", "Lua"), preferredLanguages, ", ");
+ mI18n.setPreferredLanguages(preferredLanguages);
+
initObjectBindingsForGlobalScripts(context);
initCellBindingsForGlobalScripts(context);
initObjectBindingsForLocalScripts(localContext);
diff --git a/apps/openmw/mwlua/luamanagerimp.hpp b/apps/openmw/mwlua/luamanagerimp.hpp
index 88273de7f0..d050cb9413 100644
--- a/apps/openmw/mwlua/luamanagerimp.hpp
+++ b/apps/openmw/mwlua/luamanagerimp.hpp
@@ -5,6 +5,7 @@
#include <set>
#include <components/lua/luastate.hpp>
+#include <components/lua/i18n.hpp>
#include "../mwbase/luamanager.hpp"
@@ -22,7 +23,7 @@ namespace MWLua
class LuaManager : public MWBase::LuaManager
{
public:
- LuaManager(const VFS::Manager* vfs);
+ LuaManager(const VFS::Manager* vfs, const std::string& libsDir);
// Called by engine.cpp when the environment is fully initialized.
void init();
@@ -91,6 +92,7 @@ namespace MWLua
bool mGlobalScriptsStarted = false;
LuaUtil::ScriptsConfiguration mConfiguration;
LuaUtil::LuaState mLua;
+ LuaUtil::I18nManager mI18n;
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 19b31f78be..9465d59b47 100644
--- a/apps/openmw_test_suite/CMakeLists.txt
+++ b/apps/openmw_test_suite/CMakeLists.txt
@@ -23,6 +23,7 @@ if (GTEST_FOUND AND GMOCK_FOUND)
lua/test_serialization.cpp
lua/test_querypackage.cpp
lua/test_configuration.cpp
+ lua/test_i18n.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
new file mode 100644
index 0000000000..427482be64
--- /dev/null
+++ b/apps/openmw_test_suite/lua/test_i18n.cpp
@@ -0,0 +1,110 @@
+#include "gmock/gmock.h"
+#include <gtest/gtest.h>
+
+#include <components/files/fixedpath.hpp>
+
+#include <components/lua/luastate.hpp>
+#include <components/lua/i18n.hpp>
+
+#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<VFS::Manager> 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<std::string>(l, "t1('good_morning')"), "Guten Morgen.");
+ EXPECT_EQ(get<std::string>(l, "t1('you_have_arrows', {count=1})"), "Du hast ein Pfeil.");
+ EXPECT_EQ(get<std::string>(l, "t1('you_have_arrows', {count=5})"), "Du hast 5 Pfeile.");
+ EXPECT_EQ(get<std::string>(l, "t1('Hello %{name}!', {name='World'})"), "Hallo World!");
+ EXPECT_EQ(get<std::string>(l, "t2('good_morning')"), "Morning!");
+ EXPECT_EQ(get<std::string>(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<std::string>(l, "t1('good_morning')"), "Good morning.");
+ EXPECT_EQ(get<std::string>(l, "t1('you_have_arrows', {count=1})"), "You have one arrow.");
+ EXPECT_EQ(get<std::string>(l, "t1('you_have_arrows', {count=5})"), "You have 5 arrows.");
+ EXPECT_EQ(get<std::string>(l, "t1('Hello %{name}!', {name='World'})"), "Hello World!");
+ EXPECT_EQ(get<std::string>(l, "t2('good_morning')"), "Morning!");
+ EXPECT_EQ(get<std::string>(l, "t2('you_have_arrows', {count=3})"), "Arrows count: 3");
+ }
+
+}
diff --git a/apps/openmw_test_suite/lua/test_lua.cpp b/apps/openmw_test_suite/lua/test_lua.cpp
index 4b3ecdcb2b..fe3cf14d25 100644
--- a/apps/openmw_test_suite/lua/test_lua.cpp
+++ b/apps/openmw_test_suite/lua/test_lua.cpp
@@ -106,7 +106,7 @@ return {
}
EXPECT_EQ(LuaUtil::call(script["useCounter"]).get<int>(), 45);
- EXPECT_ERROR(LuaUtil::call(script["incorrectRequire"]), "Resource 'counter.lua' not found");
+ EXPECT_ERROR(LuaUtil::call(script["incorrectRequire"]), "module not found: counter");
}
TEST_F(LuaStateTest, ReadOnly)
@@ -161,7 +161,7 @@ return {
sol::table script2 = lua.runInNewSandbox("bbb/tests.lua", "", {{"test.api", api2}});
- EXPECT_ERROR(LuaUtil::call(script1["sqr"], 3), "Resource 'sqrlib.lua' not found");
+ EXPECT_ERROR(LuaUtil::call(script1["sqr"], 3), "module not found: sqrlib");
EXPECT_EQ(LuaUtil::call(script2["sqr"], 3).get<int>(), 9);
EXPECT_EQ(LuaUtil::call(script1["apiName"]).get<std::string>(), "api1");
diff --git a/apps/openmw_test_suite/lua/test_utilpackage.cpp b/apps/openmw_test_suite/lua/test_utilpackage.cpp
index 14b7021532..953d5f50d3 100644
--- a/apps/openmw_test_suite/lua/test_utilpackage.cpp
+++ b/apps/openmw_test_suite/lua/test_utilpackage.cpp
@@ -10,12 +10,6 @@ namespace
{
using namespace testing;
- template <typename T>
- T get(sol::state& lua, std::string luaCode)
- {
- return lua.safe_script("return " + luaCode).get<T>();
- }
-
std::string getAsString(sol::state& lua, std::string luaCode)
{
return LuaUtil::toString(lua.safe_script("return " + luaCode));
diff --git a/apps/openmw_test_suite/lua/testing_util.hpp b/apps/openmw_test_suite/lua/testing_util.hpp
index 2f6810350f..ba4a418bfb 100644
--- a/apps/openmw_test_suite/lua/testing_util.hpp
+++ b/apps/openmw_test_suite/lua/testing_util.hpp
@@ -2,6 +2,7 @@
#define LUA_TESTING_UTIL_H
#include <sstream>
+#include <sol/sol.hpp>
#include <components/vfs/archive.hpp>
#include <components/vfs/manager.hpp>
@@ -9,6 +10,12 @@
namespace
{
+ template <typename T>
+ T get(sol::state& lua, const std::string& luaCode)
+ {
+ return lua.safe_script("return " + luaCode).get<T>();
+ }
+
class TestFile : public VFS::File
{
public:
diff --git a/components/CMakeLists.txt b/components/CMakeLists.txt
index 3b38489d7a..6fedf25b4c 100644
--- a/components/CMakeLists.txt
+++ b/components/CMakeLists.txt
@@ -29,7 +29,7 @@ endif (GIT_CHECKOUT)
# source files
add_component_dir (lua
- luastate scriptscontainer utilpackage serialization configuration
+ luastate scriptscontainer utilpackage serialization configuration i18n
)
add_component_dir (settings
@@ -160,7 +160,7 @@ add_component_dir (fallback
add_component_dir (queries
query luabindings
)
-
+
add_component_dir (lua_ui
widget widgetlist element layers content
text textedit window
diff --git a/components/lua/i18n.cpp b/components/lua/i18n.cpp
new file mode 100644
index 0000000000..9fd2724f75
--- /dev/null
+++ b/components/lua/i18n.cpp
@@ -0,0 +1,108 @@
+#include "i18n.hpp"
+
+#include <components/debug/debuglog.hpp>
+
+namespace sol
+{
+ template <>
+ struct is_automagical<LuaUtil::I18nManager::Context> : std::false_type {};
+}
+
+namespace LuaUtil
+{
+
+ void I18nManager::init()
+ {
+ mPreferredLanguages.push_back("en");
+ sol::usertype<Context> ctx = mLua->sol().new_usertype<Context>("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<std::string>& 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<std::string>();
+ 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");
+ 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
new file mode 100644
index 0000000000..4bc7c624f1
--- /dev/null
+++ b/components/lua/i18n.hpp
@@ -0,0 +1,41 @@
+#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<std::string>& langs);
+ const std::vector<std::string>& 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<std::string> mPreferredLanguages;
+ std::map<std::string, Context> mContexts;
+ };
+
+}
+
+#endif // COMPONENTS_LUA_I18N_H \ No newline at end of file
diff --git a/components/lua/luastate.cpp b/components/lua/luastate.cpp
index 61637d7b07..cee48b4545 100644
--- a/components/lua/luastate.cpp
+++ b/components/lua/luastate.cpp
@@ -4,17 +4,44 @@
#include <luajit.h>
#endif // NO_LUAJIT
+#include <filesystem>
+
#include <components/debug/debuglog.hpp>
namespace LuaUtil
{
- static std::string packageNameToPath(std::string_view packageName)
+ static std::string packageNameToVfsPath(std::string_view packageName, const VFS::Manager* vfs)
{
- std::string res(packageName);
- std::replace(res.begin(), res.end(), '.', '/');
- res.append(".lua");
- return res;
+ std::string path(packageName);
+ std::replace(path.begin(), path.end(), '.', '/');
+ std::string pathWithInit = path + "/init.lua";
+ path.append(".lua");
+ if (vfs->exists(path))
+ return path;
+ else if (vfs->exists(pathWithInit))
+ return pathWithInit;
+ else
+ throw std::runtime_error("module not found: " + std::string(packageName));
+ }
+
+ static std::string packageNameToPath(std::string_view packageName, const std::vector<std::string>& searchDirs)
+ {
+ std::string path(packageName);
+ std::replace(path.begin(), path.end(), '.', '/');
+ std::string pathWithInit = path + "/init.lua";
+ path.append(".lua");
+ for (const std::string& dir : searchDirs)
+ {
+ std::filesystem::path base(dir);
+ std::filesystem::path p1 = base / path;
+ if (std::filesystem::exists(p1))
+ return p1.string();
+ std::filesystem::path p2 = base / pathWithInit;
+ if (std::filesystem::exists(p2))
+ return p2.string();
+ }
+ throw std::runtime_error("module not found: " + std::string(packageName));
}
static const std::string safeFunctions[] = {
@@ -28,7 +55,7 @@ namespace LuaUtil
sol::lib::string, sol::lib::table, sol::lib::debug);
mLua["math"]["randomseed"](static_cast<unsigned>(std::time(nullptr)));
- mLua["math"]["randomseed"] = sol::nil;
+ mLua["math"]["randomseed"] = []{};
mLua["writeToLog"] = [](std::string_view s) { Log(Debug::Level::Info) << s; };
mLua.script(R"(printToLog = function(name, ...)
@@ -105,7 +132,7 @@ namespace LuaUtil
const std::string& path, const std::string& namePrefix,
const std::map<std::string, sol::object>& packages, const sol::object& hiddenData)
{
- sol::protected_function script = loadScript(path);
+ sol::protected_function script = loadScriptAndCache(path);
sol::environment env(mLua, sol::create, mSandboxEnv);
std::string envName = namePrefix + "[" + path + "]:";
@@ -122,9 +149,9 @@ namespace LuaUtil
sol::object package = packages[packageName];
if (package == sol::nil)
{
- sol::protected_function packageLoader = loadScript(packageNameToPath(packageName));
+ sol::protected_function packageLoader = loadScriptAndCache(packageNameToVfsPath(packageName, mVFS));
sol::set_environment(env, packageLoader);
- package = throwIfError(packageLoader());
+ package = call(packageLoader, packageName);
if (!package.is<sol::table>())
throw std::runtime_error("Lua package must return a table.");
packages[packageName] = package;
@@ -138,6 +165,24 @@ namespace LuaUtil
return call(script);
}
+ sol::environment LuaState::newInternalLibEnvironment()
+ {
+ sol::environment env(mLua, sol::create, mSandboxEnv);
+ sol::table loaded(mLua, sol::create);
+ for (const std::string& s : safePackages)
+ loaded[s] = mSandboxEnv[s];
+ env["require"] = [this, loaded, env](const std::string& module) mutable
+ {
+ if (loaded[module] != sol::nil)
+ return loaded[module];
+ sol::protected_function initializer = loadInternalLib(module);
+ sol::set_environment(env, initializer);
+ loaded[module] = call(initializer, module);
+ return loaded[module];
+ };
+ return env;
+ }
+
sol::protected_function_result LuaState::throwIfError(sol::protected_function_result&& res)
{
if (!res.valid() && static_cast<int>(res.get_type()) == LUA_TSTRING)
@@ -146,17 +191,31 @@ namespace LuaUtil
return std::move(res);
}
- sol::function LuaState::loadScript(const std::string& path)
+ sol::function LuaState::loadScriptAndCache(const std::string& path)
{
auto iter = mCompiledScripts.find(path);
if (iter != mCompiledScripts.end())
return mLua.load(iter->second.as_string_view(), path, sol::load_mode::binary);
+ sol::function res = loadFromVFS(path);
+ mCompiledScripts[path] = res.dump();
+ return res;
+ }
+ sol::function LuaState::loadFromVFS(const std::string& path)
+ {
std::string fileContent(std::istreambuf_iterator<char>(*mVFS->get(path)), {});
sol::load_result res = mLua.load(fileContent, path, sol::load_mode::text);
if (!res.valid())
throw std::runtime_error("Lua error: " + res.get<std::string>());
- mCompiledScripts[path] = res.get<sol::function>().dump();
+ return res;
+ }
+
+ sol::function LuaState::loadInternalLib(std::string_view libName)
+ {
+ std::string path = packageNameToPath(libName, mLibSearchPaths);
+ sol::load_result res = mLua.load_file(path, sol::load_mode::text);
+ if (!res.valid())
+ throw std::runtime_error("Lua error: " + res.get<std::string>());
return res;
}
diff --git a/components/lua/luastate.hpp b/components/lua/luastate.hpp
index 32c180c987..f9be5e9e99 100644
--- a/components/lua/luastate.hpp
+++ b/components/lua/luastate.hpp
@@ -76,12 +76,18 @@ namespace LuaUtil
const ScriptsConfiguration& getConfiguration() const { return *mConf; }
+ // Load internal Lua library. All libraries are loaded in one sandbox and shouldn't be exposed to scripts directly.
+ void addInternalLibSearchPath(const std::string& path) { mLibSearchPaths.push_back(path); }
+ sol::function loadInternalLib(std::string_view libName);
+ sol::function loadFromVFS(const std::string& path);
+ sol::environment newInternalLibEnvironment();
+
private:
static sol::protected_function_result throwIfError(sol::protected_function_result&&);
template <typename... Args>
friend sol::protected_function_result call(const sol::protected_function& fn, Args&&... args);
- sol::function loadScript(const std::string& path);
+ sol::function loadScriptAndCache(const std::string& path);
sol::state mLua;
const ScriptsConfiguration* mConf;
@@ -89,6 +95,7 @@ namespace LuaUtil
std::map<std::string, sol::bytecode> mCompiledScripts;
std::map<std::string, sol::object> mCommonPackages;
const VFS::Manager* mVFS;
+ std::vector<std::string> mLibSearchPaths;
};
// Should be used for every call of every Lua function.
diff --git a/docs/source/reference/modding/settings/lua.rst b/docs/source/reference/modding/settings/lua.rst
index 65faf884ae..919d530d18 100644
--- a/docs/source/reference/modding/settings/lua.rst
+++ b/docs/source/reference/modding/settings/lua.rst
@@ -27,3 +27,14 @@ 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/i18n.lua/CMakeLists.txt b/extern/i18n.lua/CMakeLists.txt
new file mode 100644
index 0000000000..aec4447470
--- /dev/null
+++ b/extern/i18n.lua/CMakeLists.txt
@@ -0,0 +1,17 @@
+if (NOT DEFINED OPENMW_RESOURCES_ROOT)
+ message( FATAL_ERROR "OPENMW_RESOURCES_ROOT is not set" )
+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
new file mode 100644
index 0000000000..ddf484685b
--- /dev/null
+++ b/extern/i18n.lua/LICENSE
@@ -0,0 +1,22 @@
+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
new file mode 100644
index 0000000000..8b3271c321
--- /dev/null
+++ b/extern/i18n.lua/README.md
@@ -0,0 +1,164 @@
+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: %<name>.q %<age>.d %<age>.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
new file mode 100644
index 0000000000..6bcccd0572
--- /dev/null
+++ b/extern/i18n.lua/i18n/init.lua
@@ -0,0 +1,188 @@
+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
new file mode 100644
index 0000000000..c6bb242f05
--- /dev/null
+++ b/extern/i18n.lua/i18n/interpolate.lua
@@ -0,0 +1,60 @@
+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 %<age>.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
new file mode 100644
index 0000000000..bb99804ee8
--- /dev/null
+++ b/extern/i18n.lua/i18n/plural.lua
@@ -0,0 +1,280 @@
+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
new file mode 100644
index 0000000000..0cfad42f6c
--- /dev/null
+++ b/extern/i18n.lua/i18n/variants.lua
@@ -0,0 +1,49 @@
+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
new file mode 100644
index 0000000000..eb788884ac
--- /dev/null
+++ b/extern/i18n.lua/i18n/version.lua
@@ -0,0 +1 @@
+return '0.9.2'
diff --git a/files/CMakeLists.txt b/files/CMakeLists.txt
index cea33f0f40..607ddeca49 100644
--- a/files/CMakeLists.txt
+++ b/files/CMakeLists.txt
@@ -3,3 +3,4 @@ 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/lua_api/openmw/core.lua b/files/lua_api/openmw/core.lua
index fe309a92db..016267d39d 100644
--- a/files/lua_api/openmw/core.lua
+++ b/files/lua_api/openmw/core.lua
@@ -37,6 +37,39 @@
-- @function [parent=#core] isWorldPaused
-- @return #boolean
+-------------------------------------------------------------------------------
+-- Return i18n formatting function for the given context.
+-- It is based on `i18n.lua` library.
+-- Language files should be stored in VFS as `i18n/<ContextName>/<Lang>.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 #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.',
+-- },
+-- }
+-- @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}!",
+-- }
+-- @usage
+-- local myMsg = core.i18n('MyMod')
+-- print( myMsg('good_morning') )
+-- print( myMsg('you_have_arrows', {count=5}) )
+-- print( myMsg('Hello %{name}!', {name='World'}) )
+
-------------------------------------------------------------------------------
-- @type OBJECT_TYPE
diff --git a/files/settings-default.cfg b/files/settings-default.cfg
index 57faaba11d..acd011f233 100644
--- a/files/settings-default.cfg
+++ b/files/settings-default.cfg
@@ -1123,3 +1123,7 @@ lua debug = false
# 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
+