Browse Source

Add support for translating content titles and descriptions (#12208)

rubenwardy 2 months ago
parent
commit
b4be483d3e

+ 27 - 0
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
 
 --------------------------------------------------------------------------------

+ 4 - 3
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

+ 55 - 11
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 = <comma-separated 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
 ============
 

+ 8 - 0
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
 -------

+ 9 - 2
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
 

+ 35 - 26
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<char>(is)),

+ 13 - 0
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);

+ 53 - 0
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<ModSpec> 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()
 {

+ 16 - 0
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();

+ 26 - 3
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);

+ 2 - 0
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);

+ 4 - 2
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++;