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

@ -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
--------------------------------------------------------------------------------

@ -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

@ -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
============

@ -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
-------

@ -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

@ -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)),

@ -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);

@ -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()
{

@ -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();

@ -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);

@ -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);

@ -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++;