From b4be483d3ecb08234685f9a071fd71946d041494 Mon Sep 17 00:00:00 2001 From: rubenwardy Date: Sat, 24 Feb 2024 19:13:07 +0000 Subject: [PATCH] Add support for translating content titles and descriptions (#12208) --- builtin/mainmenu/content/pkgmgr.lua | 27 ++++++++++++ builtin/mainmenu/tab_content.lua | 7 +-- doc/lua_api.md | 66 ++++++++++++++++++++++++----- doc/menu_lua_api.md | 8 ++++ doc/texture_packs.md | 11 ++++- src/content/content.cpp | 61 ++++++++++++++------------ src/content/content.h | 13 ++++++ src/gui/guiEngine.cpp | 53 +++++++++++++++++++++++ src/gui/guiEngine.h | 16 +++++++ src/script/lua_api/l_mainmenu.cpp | 29 +++++++++++-- src/script/lua_api/l_mainmenu.h | 2 + src/translation.cpp | 6 ++- 12 files changed, 252 insertions(+), 47 deletions(-) diff --git a/builtin/mainmenu/content/pkgmgr.lua b/builtin/mainmenu/content/pkgmgr.lua index 9408eb994..b167e1423 100644 --- a/builtin/mainmenu/content/pkgmgr.lua +++ b/builtin/mainmenu/content/pkgmgr.lua @@ -150,6 +150,8 @@ function pkgmgr.get_mods(path, virtual_path, listing, modpack) toadd.virtual_path = mod_virtual_path toadd.type = "mod" + pkgmgr.update_translations({ toadd }) + -- Check modpack.txt -- Note: modpack.conf is already checked above local modpackfile = io.open(mod_path .. DIR_DELIM .. "modpack.txt") @@ -189,6 +191,8 @@ function pkgmgr.get_texture_packs() load_texture_packs(txtpath_system, retval) end + pkgmgr.update_translations(retval) + table.sort(retval, function(a, b) return a.title:lower() < b.title:lower() end) @@ -775,6 +779,29 @@ function pkgmgr.update_gamelist() table.sort(pkgmgr.games, function(a, b) return a.title:lower() < b.title:lower() end) + pkgmgr.update_translations(pkgmgr.games) +end + +-------------------------------------------------------------------------------- +function pkgmgr.update_translations(list) + for _, item in ipairs(list) do + local info = core.get_content_info(item.path) + assert(info.path) + assert(info.textdomain) + + assert(not item.is_translated) + item.is_translated = true + + if info.title and info.title ~= "" then + item.title = core.get_content_translation(info.path, info.textdomain, + core.translate(info.textdomain, info.title)) + end + + if info.description and info.description ~= "" then + item.description = core.get_content_translation(info.path, info.textdomain, + core.translate(info.textdomain, info.description)) + end + end end -------------------------------------------------------------------------------- diff --git a/builtin/mainmenu/tab_content.lua b/builtin/mainmenu/tab_content.lua index abe127a69..26f2e2a3c 100644 --- a/builtin/mainmenu/tab_content.lua +++ b/builtin/mainmenu/tab_content.lua @@ -114,12 +114,13 @@ local function get_formspec(tabview, name, tabdata) modscreenshot = defaulttexturedir .. "no_screenshot.png" end - local info = core.get_content_info(selected_pkg.path) local desc = fgettext("No package description available") - if info.description and info.description:trim() ~= "" then - desc = core.formspec_escape(info.description) + if selected_pkg.description and selected_pkg.description:trim() ~= "" then + desc = core.formspec_escape(selected_pkg.description) end + local info = core.get_content_info(selected_pkg.path) + local title_and_name if selected_pkg.type == "game" then title_and_name = selected_pkg.name diff --git a/doc/lua_api.md b/doc/lua_api.md index ca9338271..87537d1fd 100644 --- a/doc/lua_api.md +++ b/doc/lua_api.md @@ -61,7 +61,8 @@ The game directory can contain the following files: * `game.conf`, with the following keys: * `title`: Required, a human-readable title to address the game, e.g. `title = Minetest Game`. * `name`: (Deprecated) same as title. - * `description`: Short description to be shown in the content tab + * `description`: Short description to be shown in the content tab. + See [Translating content meta](#translating-content-meta). * `allowed_mapgens = ` e.g. `allowed_mapgens = v5,v6,flat` Mapgens not in this list are removed from the list of mapgens for the @@ -87,10 +88,11 @@ The game directory can contain the following files: `enable_damage`, `creative_mode`, `enable_server`. * `map_persistent`: Specifies whether newly created worlds should use a persistent map backend. Defaults to `true` (= "sqlite3") - * `author`: The author of the game. It only appears when downloaded from - ContentDB. + * `author`: The author's ContentDB username. * `release`: Ignore this: Should only ever be set by ContentDB, as it is an internal ID used to track versions. + * `textdomain`: Textdomain used to translate description. Defaults to game id. + See [Translating content meta](#translating-content-meta). * `minetest.conf`: Used to set default settings when running this game. * `settingtypes.txt`: @@ -156,13 +158,14 @@ The file is a key-value store of modpack details. * `name`: The modpack name. Allows Minetest to determine the modpack name even if the folder is wrongly named. +* `title`: A human-readable title to address the modpack. See [Translating content meta](#translating-content-meta). * `description`: Description of mod to be shown in the Mods tab of the main - menu. -* `author`: The author of the modpack. It only appears when downloaded from - ContentDB. + menu. See [Translating content meta](#translating-content-meta). +* `author`: The author's ContentDB username. * `release`: Ignore this: Should only ever be set by ContentDB, as it is an internal ID used to track versions. -* `title`: A human-readable title to address the modpack. +* `textdomain`: Textdomain used to translate title and description. Defaults to modpack name. + See [Translating content meta](#translating-content-meta). Note: to support 0.4.x, please also create an empty modpack.txt file. @@ -201,17 +204,18 @@ A `Settings` file that provides meta information about the mod. * `name`: The mod name. Allows Minetest to determine the mod name even if the folder is wrongly named. +* `title`: A human-readable title to address the mod. See [Translating content meta](#translating-content-meta). * `description`: Description of mod to be shown in the Mods tab of the main - menu. + menu. See [Translating content meta](#translating-content-meta). * `depends`: A comma separated list of dependencies. These are mods that must be loaded before this mod. * `optional_depends`: A comma separated list of optional dependencies. Like a dependency, but no error if the mod doesn't exist. -* `author`: The author of the mod. It only appears when downloaded from - ContentDB. +* `author`: The author's ContentDB username. * `release`: Ignore this: Should only ever be set by ContentDB, as it is an internal ID used to track versions. -* `title`: A human-readable title to address the mod. +* `textdomain`: Textdomain used to translate title and description. Defaults to modname. + See [Translating content meta](#translating-content-meta). ### `screenshot.png` @@ -4135,6 +4139,46 @@ the table returned by `minetest.get_player_information(name)`. IMPORTANT: This functionality should only be used for sorting, filtering or similar purposes. You do not need to use this to get translated strings to show up on the client. +Translating content meta +------------------------ + +You can translate content meta, such as `title` and `description`, by placing +translations in a `locale/DOMAIN.LANG.tr` file. The textdomain defaults to the +content name, but can be customised using `textdomain` in the content's .conf. + +### Mods and Texture Packs + +Say you have a mod called `mymod` with a short description in mod.conf: + +``` +description = This is the short description +``` + +Minetest will look for translations in the `mymod` textdomain as there's no +textdomain specified in mod.conf. For example, `mymod/locale/mymod.fr.tr`: + +``` +# textdomain:mymod +This is the short description=Voici la description succincte +``` + +### Games and Modpacks + +For games and modpacks, Minetest will look for the textdomain in all mods. + +Say you have a game called `mygame` with the following game.conf: + +``` +description = This is the game's short description +textdomain = mygame +``` + +Minetest will then look for the textdomain `mygame` in all mods, for example, +`mygame/mods/anymod/locale/mygame.fr.tr`. Note that it is still recommended that your +textdomain match the mod name, but this isn't required. + + + Perlin noise ============ diff --git a/doc/menu_lua_api.md b/doc/menu_lua_api.md index 67f7edc69..3e8bb3583 100644 --- a/doc/menu_lua_api.md +++ b/doc/menu_lua_api.md @@ -323,6 +323,7 @@ Package - content which is downloadable from the content db, may or may not be i description = "description", author = "author", path = "path/to/content", + textdomain = "textdomain", -- textdomain to translate title / description with depends = {"mod", "names"}, -- mods only optional_depends = {"mod", "names"}, -- mods only } @@ -340,6 +341,13 @@ Package - content which is downloadable from the content db, may or may not be i error_message = "", -- message or nil } ``` +* `core.get_content_translation(path, domain, string)` + * Translates `string` using `domain` in content directory at `path`. + * Textdomains will be found by looking through all locale folders. + * String should contain translation markup from `core.translate(textdomain, ...)`. + * Ex: `core.get_content_translation("mods/mymod", "mymod", core.translate("mymod", "Hello World"))` + will translate "Hello World" into the current user's language + using `mods/mymod/locale/mymod.fr.tr`. Logging ------- diff --git a/doc/texture_packs.md b/doc/texture_packs.md index bea1af93a..b6f9306d9 100644 --- a/doc/texture_packs.md +++ b/doc/texture_packs.md @@ -25,8 +25,14 @@ texture pack. The name must not be “base”. ### `texture_pack.conf` A key-value config file with the following keys: -* `title` - human readable title +* `name`: The texture pack name. Allows Minetest to determine the texture pack name even if + the folder is wrongly named. +* `title` - human-readable title * `description` - short description, shown in the content tab +* `author`: The author's ContentDB username. +* `textdomain`: Textdomain used to translate title and description. + Defaults to the texture pack name. + See [Translating content meta](lua_api.md#translating-content-meta). ### `description.txt` **Deprecated**, you should use texture_pack.conf instead. @@ -205,7 +211,8 @@ Here are targets you can choose from: Nodes support all targets, but other items only support 'inventory' and 'wield'. -¹ : `N` is an integer [0,255]. Sets align_style = "world" and scale = N on the tile, refer to lua_api.md for details. +¹ : `N` is an integer [0,255]. Sets align_style = "world" and scale = N on the tile, + refer to lua_api.md for details. ### Using the special targets diff --git a/src/content/content.cpp b/src/content/content.cpp index e576943ff..ad5cc9e64 100644 --- a/src/content/content.cpp +++ b/src/content/content.cpp @@ -24,68 +24,59 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "filesys.h" #include "settings.h" -enum ContentType +ContentType getContentType(const std::string &path) { - ECT_UNKNOWN, - ECT_MOD, - ECT_MODPACK, - ECT_GAME, - ECT_TXP -}; - -ContentType getContentType(const ContentSpec &spec) -{ - std::ifstream modpack_is((spec.path + DIR_DELIM + "modpack.txt").c_str()); + std::ifstream modpack_is((path + DIR_DELIM + "modpack.txt").c_str()); if (modpack_is.good()) { modpack_is.close(); - return ECT_MODPACK; + return ContentType::MODPACK; } - std::ifstream modpack2_is((spec.path + DIR_DELIM + "modpack.conf").c_str()); + std::ifstream modpack2_is((path + DIR_DELIM + "modpack.conf").c_str()); if (modpack2_is.good()) { modpack2_is.close(); - return ECT_MODPACK; + return ContentType::MODPACK; } - std::ifstream init_is((spec.path + DIR_DELIM + "init.lua").c_str()); + std::ifstream init_is((path + DIR_DELIM + "init.lua").c_str()); if (init_is.good()) { init_is.close(); - return ECT_MOD; + return ContentType::MOD; } - std::ifstream game_is((spec.path + DIR_DELIM + "game.conf").c_str()); + std::ifstream game_is((path + DIR_DELIM + "game.conf").c_str()); if (game_is.good()) { game_is.close(); - return ECT_GAME; + return ContentType::GAME; } - std::ifstream txp_is((spec.path + DIR_DELIM + "texture_pack.conf").c_str()); + std::ifstream txp_is((path + DIR_DELIM + "texture_pack.conf").c_str()); if (txp_is.good()) { txp_is.close(); - return ECT_TXP; + return ContentType::TXP; } - return ECT_UNKNOWN; + return ContentType::UNKNOWN; } void parseContentInfo(ContentSpec &spec) { std::string conf_path; - switch (getContentType(spec)) { - case ECT_MOD: + switch (getContentType(spec.path)) { + case ContentType::MOD: spec.type = "mod"; conf_path = spec.path + DIR_DELIM + "mod.conf"; break; - case ECT_MODPACK: + case ContentType::MODPACK: spec.type = "modpack"; conf_path = spec.path + DIR_DELIM + "modpack.conf"; break; - case ECT_GAME: + case ContentType::GAME: spec.type = "game"; conf_path = spec.path + DIR_DELIM + "game.conf"; break; - case ECT_TXP: + case ContentType::TXP: spec.type = "txp"; conf_path = spec.path + DIR_DELIM + "texture_pack.conf"; break; @@ -104,6 +95,15 @@ void parseContentInfo(ContentSpec &spec) if (spec.type != "game" && conf.exists("name")) spec.name = conf.get("name"); + if (conf.exists("title")) + spec.title = conf.get("title"); + + if (spec.type == "game") { + if (spec.title.empty()) + spec.title = spec.name; + spec.name = ""; + } + if (conf.exists("description")) spec.desc = conf.get("description"); @@ -112,8 +112,17 @@ void parseContentInfo(ContentSpec &spec) if (conf.exists("release")) spec.release = conf.getS32("release"); + + if (conf.exists("textdomain")) + spec.textdomain = conf.get("textdomain"); } + if (spec.name.empty()) + spec.name = fs::GetFilenameFromPath(spec.path.c_str()); + + if (spec.textdomain.empty()) + spec.textdomain = spec.name; + if (spec.desc.empty()) { std::ifstream is((spec.path + DIR_DELIM + "description.txt").c_str()); spec.desc = std::string((std::istreambuf_iterator(is)), diff --git a/src/content/content.h b/src/content/content.h index ce09a2eb9..c7fc220dc 100644 --- a/src/content/content.h +++ b/src/content/content.h @@ -22,6 +22,16 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "convert_json.h" #include "irrlichttypes.h" +enum class ContentType +{ + UNKNOWN, + MOD, + MODPACK, + GAME, + TXP +}; + + struct ContentSpec { std::string type; @@ -37,6 +47,9 @@ struct ContentSpec /// Short description std::string desc; std::string path; + std::string textdomain; }; + +ContentType getContentType(const std::string &path); void parseContentInfo(ContentSpec &spec); diff --git a/src/gui/guiEngine.cpp b/src/gui/guiEngine.cpp index 1ae463e37..3f21e69e6 100644 --- a/src/gui/guiEngine.cpp +++ b/src/gui/guiEngine.cpp @@ -36,6 +36,8 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "client/guiscalingfilter.h" #include "irrlicht_changes/static_text.h" #include "client/tile.h" +#include "content/content.h" +#include "content/mods.h" #if USE_SOUND #include "client/sound/sound_openal.h" @@ -204,6 +206,57 @@ GUIEngine::GUIEngine(JoystickController *joystick, m_menu.reset(); } + +/******************************************************************************/ +std::string findLocaleFileInMods(const std::string &path, const std::string &filename) +{ + std::vector mods = flattenMods(getModsInPath(path, "root", true)); + + for (const auto &mod : mods) { + std::string ret = mod.path + DIR_DELIM "locale" DIR_DELIM + filename; + if (fs::PathExists(ret)) { + return ret; + } + } + + return ""; +} + +/******************************************************************************/ +Translations *GUIEngine::getContentTranslations(const std::string &path, + const std::string &domain, const std::string &lang_code) +{ + if (domain.empty() || lang_code.empty()) + return nullptr; + + std::string filename = domain + "." + lang_code + ".tr"; + std::string key = path + DIR_DELIM "locale" DIR_DELIM + filename; + + if (key == m_last_translations_key) + return &m_last_translations; + + std::string trans_path = key; + ContentType type = getContentType(path); + if (type == ContentType::GAME) + trans_path = findLocaleFileInMods(path + DIR_DELIM "mods" DIR_DELIM, filename); + else if (type == ContentType::MODPACK) + trans_path = findLocaleFileInMods(path, filename); + // We don't need to search for locale files in a mod, as there's only one `locale` folder. + + if (trans_path.empty()) + return nullptr; + + m_last_translations_key = key; + m_last_translations = {}; + + std::string data; + if (fs::ReadFile(trans_path, data)) { + m_last_translations.loadTranslation(data); + } + + return &m_last_translations; +} + /******************************************************************************/ bool GUIEngine::loadMainMenuScript() { diff --git a/src/gui/guiEngine.h b/src/gui/guiEngine.h index cb8a97942..7d33ce7c5 100644 --- a/src/gui/guiEngine.h +++ b/src/gui/guiEngine.h @@ -28,6 +28,7 @@ with this program; if not, write to the Free Software Foundation, Inc., #include "client/sound.h" #include "client/tile.h" #include "util/enriched_string.h" +#include "translation.h" /******************************************************************************/ /* Structs and macros */ @@ -165,7 +166,22 @@ public: return m_scriptdir; } + /** + * Get translations for content + * + * Only loads a single textdomain from the path, as specified by `domain`, + * for performance reasons. + * + * WARNING: Do not store the returned pointer for long as the contents may + * change with the next call to `getContentTranslations`. + * */ + Translations *getContentTranslations(const std::string &path, + const std::string &domain, const std::string &lang_code); + private: + std::string m_last_translations_key; + /** Only the most recently used translation set is kept loaded */ + Translations m_last_translations; /** find and run the main menu script */ bool loadMainMenuScript(); diff --git a/src/script/lua_api/l_mainmenu.cpp b/src/script/lua_api/l_mainmenu.cpp index 61a4e52da..0877e930d 100644 --- a/src/script/lua_api/l_mainmenu.cpp +++ b/src/script/lua_api/l_mainmenu.cpp @@ -363,6 +363,9 @@ int ModApiMainMenu::l_get_content_info(lua_State *L) lua_pushstring(L, spec.name.c_str()); lua_setfield(L, -2, "name"); + lua_pushstring(L, spec.title.c_str()); + lua_setfield(L, -2, "title"); + lua_pushstring(L, spec.type.c_str()); lua_setfield(L, -2, "type"); @@ -383,6 +386,9 @@ int ModApiMainMenu::l_get_content_info(lua_State *L) lua_pushstring(L, spec.path.c_str()); lua_setfield(L, -2, "path"); + lua_pushstring(L, spec.textdomain.c_str()); + lua_setfield(L, -2, "textdomain"); + if (spec.type == "mod") { ModSpec spec; spec.path = path; @@ -432,8 +438,7 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L) // Ignore non-string keys if (lua_type(L, -2) != LUA_TSTRING) { throw LuaError( - "Unexpected non-string key in table passed to " - "core.check_mod_configuration"); + "Unexpected non-string key in table passed to core.check_mod_configuration"); } std::string modpath = luaL_checkstring(L, -1); @@ -472,7 +477,6 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L) return 1; } - lua_newtable(L); lua_pushboolean(L, modmgr.isConsistent()); @@ -500,7 +504,25 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L) index++; } lua_setfield(L, -2, "satisfied_mods"); + return 1; +} +/******************************************************************************/ +int ModApiMainMenu::l_get_content_translation(lua_State *L) +{ + GUIEngine* engine = getGuiEngine(L); + sanity_check(engine != NULL); + + std::string path = luaL_checkstring(L, 1); + std::string domain = luaL_checkstring(L, 2); + std::string string = luaL_checkstring(L, 3); + std::string lang = gettext("LANG_CODE"); + if (lang == "LANG_CODE") + lang = ""; + + auto *translations = engine->getContentTranslations(path, domain, lang); + string = wide_to_utf8(translate_string(utf8_to_wide(string), translations)); + lua_pushstring(L, string.c_str()); return 1; } @@ -1102,6 +1124,7 @@ void ModApiMainMenu::Initialize(lua_State *L, int top) API_FCT(get_games); API_FCT(get_content_info); API_FCT(check_mod_configuration); + API_FCT(get_content_translation); API_FCT(start); API_FCT(close); API_FCT(show_keys_menu); diff --git a/src/script/lua_api/l_mainmenu.h b/src/script/lua_api/l_mainmenu.h index a5fff3872..d06ff57b4 100644 --- a/src/script/lua_api/l_mainmenu.h +++ b/src/script/lua_api/l_mainmenu.h @@ -84,6 +84,8 @@ private: static int l_check_mod_configuration(lua_State *L); + static int l_get_content_translation(lua_State *L); + //gui static int l_show_keys_menu(lua_State *L); diff --git a/src/translation.cpp b/src/translation.cpp index eabd0ec0a..336e3dd6b 100644 --- a/src/translation.cpp +++ b/src/translation.cpp @@ -54,6 +54,7 @@ const std::wstring &Translations::getTranslation( void Translations::loadTranslation(const std::string &data) { std::istringstream is(data); + std::string textdomain_narrow; std::wstring textdomain; std::string line; @@ -70,7 +71,8 @@ void Translations::loadTranslation(const std::string &data) << "\"" << std::endl; continue; } - textdomain = utf8_to_wide(trim(parts[1])); + textdomain_narrow = trim(parts[1]); + textdomain = utf8_to_wide(textdomain_narrow); } if (line.empty() || line[0] == '#') continue; @@ -116,7 +118,7 @@ void Translations::loadTranslation(const std::string &data) if (i == wline.length()) { errorstream << "Malformed translation line \"" << line << "\"" - << std::endl; + << " in text domain " << textdomain_narrow << std::endl; continue; } i++;