i18n: share localized string helper

This commit is contained in:
m1ngsama 2026-05-24 15:27:19 +08:00
parent 46f5780057
commit d1d44d0914
6 changed files with 98 additions and 80 deletions

View file

@ -54,6 +54,8 @@
module descriptions for the split between language parsing and text lookup. module descriptions for the split between language parsing and text lookup.
- `i18n_text` now indexes localized strings by `UI_LANG_*` instead of storing - `i18n_text` now indexes localized strings by `UI_LANG_*` instead of storing
English/Chinese as hard-coded struct fields. English/Chinese as hard-coded struct fields.
- `command_catalog` now uses the shared localized-string helper for help,
manual, and usage text instead of per-field English/Chinese members.
- Documented i18n and user-facing text rules for English-first source text, - Documented i18n and user-facing text rules for English-first source text,
stable command syntax, concise help copy, and translation-only localization. stable command syntax, concise help copy, and translation-only localization.

View file

@ -3,6 +3,13 @@
#include "common.h" #include "common.h"
typedef struct {
const char *text[UI_LANG_COUNT];
} i18n_string_t;
#define I18N_STRING(en_text, zh_text) \
{{ [UI_LANG_EN] = (en_text), [UI_LANG_ZH] = (zh_text) }}
typedef enum { typedef enum {
I18N_USERNAME_PROMPT, I18N_USERNAME_PROMPT,
I18N_INVALID_USERNAME, I18N_INVALID_USERNAME,
@ -62,4 +69,17 @@ ui_lang_t i18n_next_ui_lang(ui_lang_t lang);
const char *i18n_ui_lang_code(ui_lang_t lang); const char *i18n_ui_lang_code(ui_lang_t lang);
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id); const char *i18n_text(ui_lang_t lang, i18n_text_id_t id);
static inline const char *i18n_string(i18n_string_t value, ui_lang_t lang) {
if ((int)lang < 0 || lang >= UI_LANG_COUNT) {
lang = UI_LANG_EN;
}
if (value.text[lang]) {
return value.text[lang];
}
if (value.text[UI_LANG_EN]) {
return value.text[UI_LANG_EN];
}
return "";
}
#endif /* I18N_H */ #endif /* I18N_H */

View file

@ -1,17 +1,15 @@
#include "command_catalog.h" #include "command_catalog.h"
#include "i18n.h"
#include <string.h> #include <string.h>
typedef struct { typedef struct {
tnt_command_spec_t spec; tnt_command_spec_t spec;
const char *full_usage_en; i18n_string_t full_usage;
const char *full_usage_zh; i18n_string_t summary;
const char *summary_en; i18n_string_t manual_usage;
const char *summary_zh; i18n_string_t error_usage;
const char *manual_usage_en;
const char *manual_usage_zh;
const char *error_usage_en;
const char *error_usage_zh;
int manual_group; int manual_group;
bool no_args; bool no_args;
bool requires_args; bool requires_args;
@ -20,95 +18,97 @@ typedef struct {
static const command_catalog_entry_t entries[] = { static const command_catalog_entry_t entries[] = {
{ {
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}}, {TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}},
":users, :list, :who", ":users, :list, :who", I18N_STRING(":users, :list, :who", ":users, :list, :who"),
"Show online users", "显示在线用户", I18N_STRING("Show online users", "显示在线用户"),
":users", ":users", I18N_STRING(":users", ":users"),
"Usage: users\n", "用法: users\n", I18N_STRING("Usage: users\n", "用法: users\n"),
1, true, false 1, true, false
}, },
{ {
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}}, {TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}},
":msg <user> <message>, :w <user> <message>", I18N_STRING(":msg <user> <message>, :w <user> <message>",
":msg <user> <message>, :w <user> <message>", ":msg <user> <message>, :w <user> <message>"),
"Send private message", "发送私信", I18N_STRING("Send private message", "发送私信"),
":msg <user> <message>", ":msg <user> <message>", I18N_STRING(":msg <user> <message>", ":msg <user> <message>"),
"Usage: msg <user> <message>\n" I18N_STRING("Usage: msg <user> <message>\n"
" w <user> <message>\n", " w <user> <message>\n",
"用法: msg <user> <message>\n" "用法: msg <user> <message>\n"
" w <user> <message>\n", " w <user> <message>\n"),
2, false, true 2, false, true
}, },
{ {
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}}, {TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
":inbox", ":inbox", I18N_STRING(":inbox", ":inbox"),
"Show private messages", "查看私信", I18N_STRING("Show private messages", "查看私信"),
":inbox", ":inbox", I18N_STRING(":inbox", ":inbox"),
"Usage: inbox\n", "用法: inbox\n", I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
2, true, false 2, true, false
}, },
{ {
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}}, {TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
":nick <name>, :name <name>", ":nick <name>, :name <name>", I18N_STRING(":nick <name>, :name <name>",
"Change nickname", "更改昵称", ":nick <name>, :name <name>"),
":nick <name>", ":nick <name>", I18N_STRING("Change nickname", "更改昵称"),
"Usage: nick <name>\n", "用法: nick <name>\n", I18N_STRING(":nick <name>", ":nick <name>"),
I18N_STRING("Usage: nick <name>\n", "用法: nick <name>\n"),
2, false, true 2, false, true
}, },
{ {
{TNT_COMMAND_LAST, "last", {"last", NULL}}, {TNT_COMMAND_LAST, "last", {"last", NULL}},
":last [N]", ":last [N]", I18N_STRING(":last [N]", ":last [N]"),
"Show last N messages (max 50)", "显示最后 N 条消息(最多50)", I18N_STRING("Show last N messages (max 50)",
":last [N]", ":last [N]", "显示最后 N 条消息(最多50)"),
"Usage: last [N] (N: 1-50, default 10)\n", I18N_STRING(":last [N]", ":last [N]"),
"用法: last [N] (N: 1-50默认 10)\n", I18N_STRING("Usage: last [N] (N: 1-50, default 10)\n",
"用法: last [N] (N: 1-50默认 10)\n"),
1, false, false 1, false, false
}, },
{ {
{TNT_COMMAND_SEARCH, "search", {"search", NULL}}, {TNT_COMMAND_SEARCH, "search", {"search", NULL}},
":search <keyword>", ":search <keyword>", I18N_STRING(":search <keyword>", ":search <keyword>"),
"Search message history", "搜索消息历史", I18N_STRING("Search message history", "搜索消息历史"),
":search <keyword>", ":search <keyword>", I18N_STRING(":search <keyword>", ":search <keyword>"),
"Usage: search <keyword>\n", "用法: search <keyword>\n", I18N_STRING("Usage: search <keyword>\n", "用法: search <keyword>\n"),
1, false, true 1, false, true
}, },
{ {
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}}, {TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}},
":mute-joins, :mute", ":mute-joins, :mute", I18N_STRING(":mute-joins, :mute", ":mute-joins, :mute"),
"Toggle join/leave notices", "切换加入/离开提示", I18N_STRING("Toggle join/leave notices", "切换加入/离开提示"),
":mute-joins", ":mute-joins", I18N_STRING(":mute-joins", ":mute-joins"),
"Usage: mute-joins\n", "用法: mute-joins\n", I18N_STRING("Usage: mute-joins\n", "用法: mute-joins\n"),
3, true, false 3, true, false
}, },
{ {
{TNT_COMMAND_HELP, "help", {"help", NULL}}, {TNT_COMMAND_HELP, "help", {"help", NULL}},
":help", ":help", I18N_STRING(":help", ":help"),
"Show concise manual", "显示简明手册", I18N_STRING("Show concise manual", "显示简明手册"),
NULL, NULL, I18N_STRING(NULL, NULL),
"Usage: help\n", "用法: help\n", I18N_STRING("Usage: help\n", "用法: help\n"),
0, true, false 0, true, false
}, },
{ {
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}}, {TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}},
":lang <en|zh>", ":lang <en|zh>", I18N_STRING(":lang <en|zh>", ":lang <en|zh>"),
"Switch UI language", "切换界面语言", I18N_STRING("Switch UI language", "切换界面语言"),
NULL, NULL, I18N_STRING(NULL, NULL),
"Usage: lang <en|zh>\n", "用法: lang <en|zh>\n", I18N_STRING("Usage: lang <en|zh>\n", "用法: lang <en|zh>\n"),
0, false, false 0, false, false
}, },
{ {
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}}, {TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}},
":clear, :cls", ":clear, :cls", I18N_STRING(":clear, :cls", ":clear, :cls"),
"Clear command output", "清空命令输出", I18N_STRING("Clear command output", "清空命令输出"),
":clear", ":clear", I18N_STRING(":clear", ":clear"),
"Usage: clear\n", "用法: clear\n", I18N_STRING("Usage: clear\n", "用法: clear\n"),
3, true, false 3, true, false
}, },
{ {
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}}, {TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}},
":q, :quit, :exit", ":q, :quit, :exit", I18N_STRING(":q, :quit, :exit", ":q, :quit, :exit"),
"Disconnect", "断开连接", I18N_STRING("Disconnect", "断开连接"),
":q", ":q", I18N_STRING(":q", ":q"),
"Usage: q\n", "用法: q\n", I18N_STRING("Usage: q\n", "用法: q\n"),
3, true, false 3, true, false
} }
}; };
@ -265,10 +265,8 @@ const char *command_catalog_suggest(const char *name) {
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos, void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) { ui_lang_t lang) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) { for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *usage = lang == UI_LANG_ZH ? entries[i].full_usage_zh const char *usage = i18n_string(entries[i].full_usage, lang);
: entries[i].full_usage_en; const char *summary = i18n_string(entries[i].summary, lang);
const char *summary = lang == UI_LANG_ZH ? entries[i].summary_zh
: entries[i].summary_en;
buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n", buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n",
usage, summary); usage, summary);
} }
@ -285,9 +283,8 @@ void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
if (entries[i].manual_group != group) { if (entries[i].manual_group != group) {
continue; continue;
} }
usage = lang == UI_LANG_ZH ? entries[i].manual_usage_zh usage = i18n_string(entries[i].manual_usage, lang);
: entries[i].manual_usage_en; if (!usage || usage[0] == '\0') {
if (!usage) {
continue; continue;
} }
if (!first) { if (!first) {
@ -309,7 +306,6 @@ void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
return; return;
} }
usage = lang == UI_LANG_ZH ? entry->error_usage_zh usage = i18n_string(entry->error_usage, lang);
: entry->error_usage_en;
buffer_appendf(buffer, buf_size, pos, "%s", usage); buffer_appendf(buffer, buf_size, pos, "%s", usage);
} }

View file

@ -1,8 +1,6 @@
#include "i18n.h" #include "i18n.h"
typedef struct { typedef i18n_string_t i18n_text_entry_t;
const char *text[UI_LANG_COUNT];
} i18n_text_entry_t;
static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = { static const i18n_text_entry_t text_catalog[I18N_TEXT_COUNT] = {
[I18N_USERNAME_PROMPT] = { [I18N_USERNAME_PROMPT] = {
@ -208,16 +206,6 @@ const char *i18n_text(ui_lang_t lang, i18n_text_id_t id) {
return ""; return "";
} }
if ((int)lang < 0 || lang >= UI_LANG_COUNT) {
lang = UI_LANG_EN;
}
const i18n_text_entry_t *entry = &text_catalog[id]; const i18n_text_entry_t *entry = &text_catalog[id];
if (entry->text[lang]) { return i18n_string(*entry, lang);
return entry->text[lang];
}
if (entry->text[UI_LANG_EN]) {
return entry->text[UI_LANG_EN];
}
return "";
} }

View file

@ -119,6 +119,12 @@ TEST(generates_localized_usage) {
assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0); assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0);
assert(strcmp(zh, "用法: msg <user> <message>\n" assert(strcmp(zh, "用法: msg <user> <message>\n"
" w <user> <message>\n") == 0); " w <user> <message>\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_USERS, (ui_lang_t)99);
assert(strcmp(en, "Usage: users\n") == 0);
} }
int main(void) { int main(void) {

View file

@ -79,6 +79,12 @@ TEST(default_uses_locale_when_no_tnt_lang) {
} }
TEST(text_lookup_matches_language) { TEST(text_lookup_matches_language) {
i18n_string_t sample = I18N_STRING("fallback", "替代");
assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0);
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0);
assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0);
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT), assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL); "display name") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_USERNAME_PROMPT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_USERNAME_PROMPT),