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

This commit is contained in:
rubenwardy 2024-02-24 19:13:07 +00:00 committed by GitHub
parent 57de599a29
commit b4be483d3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 252 additions and 47 deletions

View File

@ -150,6 +150,8 @@ function pkgmgr.get_mods(path, virtual_path, listing, modpack)
toadd.virtual_path = mod_virtual_path toadd.virtual_path = mod_virtual_path
toadd.type = "mod" toadd.type = "mod"
pkgmgr.update_translations({ toadd })
-- Check modpack.txt -- Check modpack.txt
-- Note: modpack.conf is already checked above -- Note: modpack.conf is already checked above
local modpackfile = io.open(mod_path .. DIR_DELIM .. "modpack.txt") 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) load_texture_packs(txtpath_system, retval)
end end
pkgmgr.update_translations(retval)
table.sort(retval, function(a, b) table.sort(retval, function(a, b)
return a.title:lower() < b.title:lower() return a.title:lower() < b.title:lower()
end) end)
@ -775,6 +779,29 @@ function pkgmgr.update_gamelist()
table.sort(pkgmgr.games, function(a, b) table.sort(pkgmgr.games, function(a, b)
return a.title:lower() < b.title:lower() return a.title:lower() < b.title:lower()
end) 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 end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View File

@ -114,12 +114,13 @@ local function get_formspec(tabview, name, tabdata)
modscreenshot = defaulttexturedir .. "no_screenshot.png" modscreenshot = defaulttexturedir .. "no_screenshot.png"
end end
local info = core.get_content_info(selected_pkg.path)
local desc = fgettext("No package description available") local desc = fgettext("No package description available")
if info.description and info.description:trim() ~= "" then if selected_pkg.description and selected_pkg.description:trim() ~= "" then
desc = core.formspec_escape(info.description) desc = core.formspec_escape(selected_pkg.description)
end end
local info = core.get_content_info(selected_pkg.path)
local title_and_name local title_and_name
if selected_pkg.type == "game" then if selected_pkg.type == "game" then
title_and_name = selected_pkg.name title_and_name = selected_pkg.name

View File

@ -61,7 +61,8 @@ The game directory can contain the following files:
* `game.conf`, with the following keys: * `game.conf`, with the following keys:
* `title`: Required, a human-readable title to address the game, e.g. `title = Minetest Game`. * `title`: Required, a human-readable title to address the game, e.g. `title = Minetest Game`.
* `name`: (Deprecated) same as title. * `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>` * `allowed_mapgens = <comma-separated mapgens>`
e.g. `allowed_mapgens = v5,v6,flat` e.g. `allowed_mapgens = v5,v6,flat`
Mapgens not in this list are removed from the list of mapgens for the 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`. `enable_damage`, `creative_mode`, `enable_server`.
* `map_persistent`: Specifies whether newly created worlds should use * `map_persistent`: Specifies whether newly created worlds should use
a persistent map backend. Defaults to `true` (= "sqlite3") a persistent map backend. Defaults to `true` (= "sqlite3")
* `author`: The author of the game. It only appears when downloaded from * `author`: The author's ContentDB username.
ContentDB.
* `release`: Ignore this: Should only ever be set by ContentDB, as it is * `release`: Ignore this: Should only ever be set by ContentDB, as it is
an internal ID used to track versions. 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`: * `minetest.conf`:
Used to set default settings when running this game. Used to set default settings when running this game.
* `settingtypes.txt`: * `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 * `name`: The modpack name. Allows Minetest to determine the modpack name even
if the folder is wrongly named. 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 * `description`: Description of mod to be shown in the Mods tab of the main
menu. menu. See [Translating content meta](#translating-content-meta).
* `author`: The author of the modpack. It only appears when downloaded from * `author`: The author's ContentDB username.
ContentDB.
* `release`: Ignore this: Should only ever be set by ContentDB, as it is an * `release`: Ignore this: Should only ever be set by ContentDB, as it is an
internal ID used to track versions. 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. 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 * `name`: The mod name. Allows Minetest to determine the mod name even if the
folder is wrongly named. 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 * `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 * `depends`: A comma separated list of dependencies. These are mods that must be
loaded before this mod. loaded before this mod.
* `optional_depends`: A comma separated list of optional dependencies. * `optional_depends`: A comma separated list of optional dependencies.
Like a dependency, but no error if the mod doesn't exist. Like a dependency, but no error if the mod doesn't exist.
* `author`: The author of the mod. It only appears when downloaded from * `author`: The author's ContentDB username.
ContentDB.
* `release`: Ignore this: Should only ever be set by ContentDB, as it is an * `release`: Ignore this: Should only ever be set by ContentDB, as it is an
internal ID used to track versions. 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` ### `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. 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. 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 Perlin noise
============ ============

View File

@ -323,6 +323,7 @@ Package - content which is downloadable from the content db, may or may not be i
description = "description", description = "description",
author = "author", author = "author",
path = "path/to/content", path = "path/to/content",
textdomain = "textdomain", -- textdomain to translate title / description with
depends = {"mod", "names"}, -- mods only depends = {"mod", "names"}, -- mods only
optional_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 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 Logging
------- -------

View File

@ -25,8 +25,14 @@ texture pack. The name must not be “base”.
### `texture_pack.conf` ### `texture_pack.conf`
A key-value config file with the following keys: 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 * `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` ### `description.txt`
**Deprecated**, you should use texture_pack.conf instead. **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' Nodes support all targets, but other items only support 'inventory'
and 'wield'. 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 ### Using the special targets

View File

@ -24,68 +24,59 @@ with this program; if not, write to the Free Software Foundation, Inc.,
#include "filesys.h" #include "filesys.h"
#include "settings.h" #include "settings.h"
enum ContentType ContentType getContentType(const std::string &path)
{ {
ECT_UNKNOWN, std::ifstream modpack_is((path + DIR_DELIM + "modpack.txt").c_str());
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());
if (modpack_is.good()) { if (modpack_is.good()) {
modpack_is.close(); 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()) { if (modpack2_is.good()) {
modpack2_is.close(); 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()) { if (init_is.good()) {
init_is.close(); 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()) { if (game_is.good()) {
game_is.close(); 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()) { if (txp_is.good()) {
txp_is.close(); txp_is.close();
return ECT_TXP; return ContentType::TXP;
} }
return ECT_UNKNOWN; return ContentType::UNKNOWN;
} }
void parseContentInfo(ContentSpec &spec) void parseContentInfo(ContentSpec &spec)
{ {
std::string conf_path; std::string conf_path;
switch (getContentType(spec)) { switch (getContentType(spec.path)) {
case ECT_MOD: case ContentType::MOD:
spec.type = "mod"; spec.type = "mod";
conf_path = spec.path + DIR_DELIM + "mod.conf"; conf_path = spec.path + DIR_DELIM + "mod.conf";
break; break;
case ECT_MODPACK: case ContentType::MODPACK:
spec.type = "modpack"; spec.type = "modpack";
conf_path = spec.path + DIR_DELIM + "modpack.conf"; conf_path = spec.path + DIR_DELIM + "modpack.conf";
break; break;
case ECT_GAME: case ContentType::GAME:
spec.type = "game"; spec.type = "game";
conf_path = spec.path + DIR_DELIM + "game.conf"; conf_path = spec.path + DIR_DELIM + "game.conf";
break; break;
case ECT_TXP: case ContentType::TXP:
spec.type = "txp"; spec.type = "txp";
conf_path = spec.path + DIR_DELIM + "texture_pack.conf"; conf_path = spec.path + DIR_DELIM + "texture_pack.conf";
break; break;
@ -104,6 +95,15 @@ void parseContentInfo(ContentSpec &spec)
if (spec.type != "game" && conf.exists("name")) if (spec.type != "game" && conf.exists("name"))
spec.name = conf.get("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")) if (conf.exists("description"))
spec.desc = conf.get("description"); spec.desc = conf.get("description");
@ -112,8 +112,17 @@ void parseContentInfo(ContentSpec &spec)
if (conf.exists("release")) if (conf.exists("release"))
spec.release = conf.getS32("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()) { if (spec.desc.empty()) {
std::ifstream is((spec.path + DIR_DELIM + "description.txt").c_str()); std::ifstream is((spec.path + DIR_DELIM + "description.txt").c_str());
spec.desc = std::string((std::istreambuf_iterator<char>(is)), spec.desc = std::string((std::istreambuf_iterator<char>(is)),

View File

@ -22,6 +22,16 @@ with this program; if not, write to the Free Software Foundation, Inc.,
#include "convert_json.h" #include "convert_json.h"
#include "irrlichttypes.h" #include "irrlichttypes.h"
enum class ContentType
{
UNKNOWN,
MOD,
MODPACK,
GAME,
TXP
};
struct ContentSpec struct ContentSpec
{ {
std::string type; std::string type;
@ -37,6 +47,9 @@ struct ContentSpec
/// Short description /// Short description
std::string desc; std::string desc;
std::string path; std::string path;
std::string textdomain;
}; };
ContentType getContentType(const std::string &path);
void parseContentInfo(ContentSpec &spec); void parseContentInfo(ContentSpec &spec);

View File

@ -36,6 +36,8 @@ with this program; if not, write to the Free Software Foundation, Inc.,
#include "client/guiscalingfilter.h" #include "client/guiscalingfilter.h"
#include "irrlicht_changes/static_text.h" #include "irrlicht_changes/static_text.h"
#include "client/tile.h" #include "client/tile.h"
#include "content/content.h"
#include "content/mods.h"
#if USE_SOUND #if USE_SOUND
#include "client/sound/sound_openal.h" #include "client/sound/sound_openal.h"
@ -204,6 +206,57 @@ GUIEngine::GUIEngine(JoystickController *joystick,
m_menu.reset(); 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() bool GUIEngine::loadMainMenuScript()
{ {

View File

@ -28,6 +28,7 @@ with this program; if not, write to the Free Software Foundation, Inc.,
#include "client/sound.h" #include "client/sound.h"
#include "client/tile.h" #include "client/tile.h"
#include "util/enriched_string.h" #include "util/enriched_string.h"
#include "translation.h"
/******************************************************************************/ /******************************************************************************/
/* Structs and macros */ /* Structs and macros */
@ -165,7 +166,22 @@ public:
return m_scriptdir; 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: 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 */ /** find and run the main menu script */
bool loadMainMenuScript(); bool loadMainMenuScript();

View File

@ -363,6 +363,9 @@ int ModApiMainMenu::l_get_content_info(lua_State *L)
lua_pushstring(L, spec.name.c_str()); lua_pushstring(L, spec.name.c_str());
lua_setfield(L, -2, "name"); 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_pushstring(L, spec.type.c_str());
lua_setfield(L, -2, "type"); 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_pushstring(L, spec.path.c_str());
lua_setfield(L, -2, "path"); lua_setfield(L, -2, "path");
lua_pushstring(L, spec.textdomain.c_str());
lua_setfield(L, -2, "textdomain");
if (spec.type == "mod") { if (spec.type == "mod") {
ModSpec spec; ModSpec spec;
spec.path = path; spec.path = path;
@ -432,8 +438,7 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L)
// Ignore non-string keys // Ignore non-string keys
if (lua_type(L, -2) != LUA_TSTRING) { if (lua_type(L, -2) != LUA_TSTRING) {
throw LuaError( throw LuaError(
"Unexpected non-string key in table passed to " "Unexpected non-string key in table passed to core.check_mod_configuration");
"core.check_mod_configuration");
} }
std::string modpath = luaL_checkstring(L, -1); std::string modpath = luaL_checkstring(L, -1);
@ -472,7 +477,6 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L)
return 1; return 1;
} }
lua_newtable(L); lua_newtable(L);
lua_pushboolean(L, modmgr.isConsistent()); lua_pushboolean(L, modmgr.isConsistent());
@ -500,7 +504,25 @@ int ModApiMainMenu::l_check_mod_configuration(lua_State *L)
index++; index++;
} }
lua_setfield(L, -2, "satisfied_mods"); 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; return 1;
} }
@ -1102,6 +1124,7 @@ void ModApiMainMenu::Initialize(lua_State *L, int top)
API_FCT(get_games); API_FCT(get_games);
API_FCT(get_content_info); API_FCT(get_content_info);
API_FCT(check_mod_configuration); API_FCT(check_mod_configuration);
API_FCT(get_content_translation);
API_FCT(start); API_FCT(start);
API_FCT(close); API_FCT(close);
API_FCT(show_keys_menu); API_FCT(show_keys_menu);

View File

@ -84,6 +84,8 @@ private:
static int l_check_mod_configuration(lua_State *L); static int l_check_mod_configuration(lua_State *L);
static int l_get_content_translation(lua_State *L);
//gui //gui
static int l_show_keys_menu(lua_State *L); static int l_show_keys_menu(lua_State *L);

View File

@ -54,6 +54,7 @@ const std::wstring &Translations::getTranslation(
void Translations::loadTranslation(const std::string &data) void Translations::loadTranslation(const std::string &data)
{ {
std::istringstream is(data); std::istringstream is(data);
std::string textdomain_narrow;
std::wstring textdomain; std::wstring textdomain;
std::string line; std::string line;
@ -70,7 +71,8 @@ void Translations::loadTranslation(const std::string &data)
<< "\"" << std::endl; << "\"" << std::endl;
continue; continue;
} }
textdomain = utf8_to_wide(trim(parts[1])); textdomain_narrow = trim(parts[1]);
textdomain = utf8_to_wide(textdomain_narrow);
} }
if (line.empty() || line[0] == '#') if (line.empty() || line[0] == '#')
continue; continue;
@ -116,7 +118,7 @@ void Translations::loadTranslation(const std::string &data)
if (i == wline.length()) { if (i == wline.length()) {
errorstream << "Malformed translation line \"" << line << "\"" errorstream << "Malformed translation line \"" << line << "\""
<< std::endl; << " in text domain " << textdomain_narrow << std::endl;
continue; continue;
} }
i++; i++;