From f91a5499d353f0d8ffc018268380afcf74c626af Mon Sep 17 00:00:00 2001 From: Petr Mikheev Date: Sun, 26 Dec 2021 19:12:04 +0100 Subject: [PATCH 1/2] Add extern/i18n.lua --- extern/i18n.lua/CMakeLists.txt | 17 ++ extern/i18n.lua/LICENSE | 22 +++ extern/i18n.lua/README.md | 164 ++++++++++++++++ extern/i18n.lua/i18n/init.lua | 188 ++++++++++++++++++ extern/i18n.lua/i18n/interpolate.lua | 60 ++++++ extern/i18n.lua/i18n/plural.lua | 280 +++++++++++++++++++++++++++ extern/i18n.lua/i18n/variants.lua | 49 +++++ extern/i18n.lua/i18n/version.lua | 1 + files/CMakeLists.txt | 1 + 9 files changed, 782 insertions(+) create mode 100644 extern/i18n.lua/CMakeLists.txt create mode 100644 extern/i18n.lua/LICENSE create mode 100644 extern/i18n.lua/README.md create mode 100644 extern/i18n.lua/i18n/init.lua create mode 100644 extern/i18n.lua/i18n/interpolate.lua create mode 100644 extern/i18n.lua/i18n/plural.lua create mode 100644 extern/i18n.lua/i18n/variants.lua create mode 100644 extern/i18n.lua/i18n/version.lua 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: %.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 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 %.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) From 0f246e73653fb1790c0a7c3cdf61e2197bbbade4 Mon Sep 17 00:00:00 2001 From: Petr Mikheev Date: Sun, 26 Dec 2021 21:49:20 +0100 Subject: [PATCH 2/2] Use a separate instance of Lua i18n for every context --- apps/openmw/engine.cpp | 2 +- apps/openmw/mwlua/context.hpp | 2 + apps/openmw/mwlua/luabindings.cpp | 4 +- apps/openmw/mwlua/luamanagerimp.cpp | 11 +- apps/openmw/mwlua/luamanagerimp.hpp | 4 +- apps/openmw_test_suite/CMakeLists.txt | 1 + apps/openmw_test_suite/lua/test_i18n.cpp | 110 ++++++++++++++++++ apps/openmw_test_suite/lua/test_lua.cpp | 4 +- .../lua/test_utilpackage.cpp | 6 - apps/openmw_test_suite/lua/testing_util.hpp | 7 ++ components/CMakeLists.txt | 4 +- components/lua/i18n.cpp | 108 +++++++++++++++++ components/lua/i18n.hpp | 41 +++++++ components/lua/luastate.cpp | 81 +++++++++++-- components/lua/luastate.hpp | 9 +- .../source/reference/modding/settings/lua.rst | 11 ++ files/lua_api/openmw/core.lua | 33 ++++++ files/settings-default.cfg | 4 + 18 files changed, 416 insertions(+), 26 deletions(-) create mode 100644 apps/openmw_test_suite/lua/test_i18n.cpp create mode 100644 components/lua/i18n.cpp create mode 100644 components/lua/i18n.hpp 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 +#include #include #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 #include +#include + #include #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 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 #include +#include #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 + +#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_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(), 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(), 9); EXPECT_EQ(LuaUtil::call(script1["apiName"]).get(), "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 - T get(sol::state& lua, std::string luaCode) - { - return lua.safe_script("return " + luaCode).get(); - } - 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 +#include #include #include @@ -9,6 +10,12 @@ namespace { + template + T get(sol::state& lua, const std::string& luaCode) + { + return lua.safe_script("return " + luaCode).get(); + } + 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 + +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"); + 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& 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/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 #endif // NO_LUAJIT +#include + #include 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& 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(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& 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()) 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(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(*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()); - mCompiledScripts[path] = res.get().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()); 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 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 mCompiledScripts; std::map mCommonPackages; const VFS::Manager* mVFS; + std::vector 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/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//.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 +