From ea9a2c3b2cd0703e7586f0c124b7e99af10fab1c Mon Sep 17 00:00:00 2001 From: Haoyu Qiu Date: Wed, 29 Oct 2025 17:44:13 +0800 Subject: [PATCH] Add CSV translation template generation --- core/string/translation.h | 6 +- doc/classes/EditorTranslationParserPlugin.xml | 4 +- doc/classes/Node.xml | 4 +- .../resource_importer_csv_translation.cpp | 5 +- editor/translations/localization_editor.cpp | 156 +++++----- editor/translations/localization_editor.h | 24 +- editor/translations/pot_generator.cpp | 260 ---------------- editor/translations/template_generator.cpp | 285 ++++++++++++++++++ .../{pot_generator.h => template_generator.h} | 34 +-- 9 files changed, 404 insertions(+), 374 deletions(-) delete mode 100644 editor/translations/pot_generator.cpp create mode 100644 editor/translations/template_generator.cpp rename editor/translations/{pot_generator.h => template_generator.h} (72%) diff --git a/core/string/translation.h b/core/string/translation.h index befb538079d..549ff7ca9db 100644 --- a/core/string/translation.h +++ b/core/string/translation.h @@ -40,8 +40,7 @@ class Translation : public Resource { OBJ_SAVE_TYPE(Translation); RES_BASE_EXTENSION("translation"); - String locale = "en"; - +public: struct MessageKey { StringName msgctxt; StringName msgid; @@ -56,6 +55,9 @@ class Translation : public Resource { } }; +private: + String locale = "en"; + HashMap, MessageKey> translation_map; mutable PluralRules *plural_rules_cache = nullptr; diff --git a/doc/classes/EditorTranslationParserPlugin.xml b/doc/classes/EditorTranslationParserPlugin.xml index 9eaa71850f4..41b66ef9d76 100644 --- a/doc/classes/EditorTranslationParserPlugin.xml +++ b/doc/classes/EditorTranslationParserPlugin.xml @@ -6,8 +6,8 @@ [EditorTranslationParserPlugin] is invoked when a file is being parsed to extract strings that require translation. To define the parsing and string extraction logic, override the [method _parse_file] method in script. The return value should be an [Array] of [PackedStringArray]s, one for each extracted translatable string. Each entry should contain [code][msgid, msgctxt, msgid_plural, comment, source_line][/code], where all except [code]msgid[/code] are optional. Empty strings will be ignored. - The extracted strings will be written into a POT file selected by user under "POT Generation" in "Localization" tab in "Project Settings" menu. - Below shows an example of a custom parser that extracts strings from a CSV file to write into a POT. + The extracted strings will be written into a translation template file selected by user under "Template Generation" in "Localization" tab in "Project Settings" menu. + Below shows an example of a custom parser that extracts strings from a CSV file to write into a template. [codeblocks] [gdscript] @tool diff --git a/doc/classes/Node.xml b/doc/classes/Node.xml index 0c6cd03170b..6b0744e765d 100644 --- a/doc/classes/Node.xml +++ b/doc/classes/Node.xml @@ -1037,7 +1037,7 @@ - Defines if any text should automatically change to its translated version depending on the current locale (for nodes such as [Label], [RichTextLabel], [Window], etc.). Also decides if the node's strings should be parsed for POT generation. + Defines if any text should automatically change to its translated version depending on the current locale (for nodes such as [Label], [RichTextLabel], [Window], etc.). Also decides if the node's strings should be parsed for translation template generation. [b]Note:[/b] For the root node, auto translate mode can also be set via [member ProjectSettings.internationalization/rendering/root_node_auto_translate]. @@ -1397,7 +1397,7 @@ Never automatically translate. This is the inverse of [constant AUTO_TRANSLATE_MODE_ALWAYS]. - String parsing for POT generation will be skipped for this node and children that are set to [constant AUTO_TRANSLATE_MODE_INHERIT]. + String parsing for translation template generation will be skipped for this node and children that are set to [constant AUTO_TRANSLATE_MODE_INHERIT]. diff --git a/editor/import/resource_importer_csv_translation.cpp b/editor/import/resource_importer_csv_translation.cpp index cf3cb0aaab9..1a486db152c 100644 --- a/editor/import/resource_importer_csv_translation.cpp +++ b/editor/import/resource_importer_csv_translation.cpp @@ -121,7 +121,10 @@ Error ResourceImporterCSVTranslation::import(ResourceUID::ID p_source_id, const column_to_translation[i] = translation; } - ERR_FAIL_COND_V_MSG(column_to_translation.is_empty(), ERR_PARSE_ERROR, "Error importing CSV translation: The CSV file must have at least one column for key and one column for translation."); + if (column_to_translation.is_empty()) { + WARN_PRINT(vformat("CSV file '%s' does not contain any translation.", p_source_file)); + return OK; + } } // Parse content rows. diff --git a/editor/translations/localization_editor.cpp b/editor/translations/localization_editor.cpp index 8d01d32509a..43e9b228a0e 100644 --- a/editor/translations/localization_editor.cpp +++ b/editor/translations/localization_editor.cpp @@ -37,7 +37,7 @@ #include "editor/gui/editor_file_dialog.h" #include "editor/settings/editor_settings.h" #include "editor/translations/editor_translation_parser.h" -#include "editor/translations/pot_generator.h" +#include "editor/translations/template_generator.h" #include "scene/gui/control.h" #include "scene/gui/tab_container.h" @@ -45,8 +45,8 @@ void LocalizationEditor::_notification(int p_what) { switch (p_what) { case NOTIFICATION_ENTER_TREE: { translation_list->connect("button_clicked", callable_mp(this, &LocalizationEditor::_translation_delete)); - translation_pot_list->connect("button_clicked", callable_mp(this, &LocalizationEditor::_pot_delete)); - translation_pot_add_builtin->set_pressed(GLOBAL_GET("internationalization/locale/translation_add_builtin_strings_to_pot")); + template_source_list->connect("button_clicked", callable_mp(this, &LocalizationEditor::_template_source_delete)); + template_add_builtin->set_pressed(GLOBAL_GET("internationalization/locale/translation_add_builtin_strings_to_pot")); List tfn; ResourceLoader::get_recognized_extensions_for_type("Translation", &tfn); @@ -62,8 +62,9 @@ void LocalizationEditor::_notification(int p_what) { translation_res_option_file_open_dialog->add_filter("*." + E); } - _update_pot_file_extensions(); - pot_generate_dialog->add_filter("*.pot"); + _update_template_source_file_extensions(); + template_generate_dialog->add_filter("*.pot"); + template_generate_dialog->add_filter("*.csv"); } break; case NOTIFICATION_DRAG_END: { @@ -342,12 +343,12 @@ void LocalizationEditor::_translation_res_option_delete(Object *p_item, int p_co undo_redo->commit_action(); } -void LocalizationEditor::_pot_add(const PackedStringArray &p_paths) { - PackedStringArray pot_translations = GLOBAL_GET("internationalization/locale/translations_pot_files"); +void LocalizationEditor::_template_source_add(const PackedStringArray &p_paths) { + PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); int count = 0; for (const String &path : p_paths) { - if (!pot_translations.has(path)) { - pot_translations.push_back(path); + if (!sources.has(path)) { + sources.push_back(path); count += 1; } } @@ -356,8 +357,8 @@ void LocalizationEditor::_pot_add(const PackedStringArray &p_paths) { } EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton(); - undo_redo->create_action(vformat(TTRN("Add %d file for POT generation", "Add %d files for POT generation", count), count)); - undo_redo->add_do_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", pot_translations); + undo_redo->create_action(vformat(TTRN("Add %d file for template generation", "Add %d files for template generation", count), count)); + undo_redo->add_do_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", sources); undo_redo->add_undo_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", GLOBAL_GET("internationalization/locale/translations_pot_files")); undo_redo->add_do_method(this, "update_translations"); undo_redo->add_undo_method(this, "update_translations"); @@ -366,7 +367,7 @@ void LocalizationEditor::_pot_add(const PackedStringArray &p_paths) { undo_redo->commit_action(); } -void LocalizationEditor::_pot_delete(Object *p_item, int p_column, int p_button, MouseButton p_mouse_button) { +void LocalizationEditor::_template_source_delete(Object *p_item, int p_column, int p_button, MouseButton p_mouse_button) { if (p_mouse_button != MouseButton::LEFT) { return; } @@ -376,15 +377,15 @@ void LocalizationEditor::_pot_delete(Object *p_item, int p_column, int p_button, int idx = ti->get_metadata(0); - PackedStringArray pot_translations = GLOBAL_GET("internationalization/locale/translations_pot_files"); + PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); - ERR_FAIL_INDEX(idx, pot_translations.size()); + ERR_FAIL_INDEX(idx, sources.size()); - pot_translations.remove_at(idx); + sources.remove_at(idx); EditorUndoRedoManager *undo_redo = EditorUndoRedoManager::get_singleton(); - undo_redo->create_action(TTR("Remove file from POT generation")); - undo_redo->add_do_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", pot_translations); + undo_redo->create_action(TTR("Remove file from template generation")); + undo_redo->add_do_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", sources); undo_redo->add_undo_property(ProjectSettings::get_singleton(), "internationalization/locale/translations_pot_files", GLOBAL_GET("internationalization/locale/translations_pot_files")); undo_redo->add_do_method(this, "update_translations"); undo_redo->add_undo_method(this, "update_translations"); @@ -393,30 +394,35 @@ void LocalizationEditor::_pot_delete(Object *p_item, int p_column, int p_button, undo_redo->commit_action(); } -void LocalizationEditor::_pot_file_open() { - pot_file_open_dialog->popup_file_dialog(); +void LocalizationEditor::_template_source_file_open() { + template_source_open_dialog->popup_file_dialog(); } -void LocalizationEditor::_pot_generate_open() { - pot_generate_dialog->popup_file_dialog(); +void LocalizationEditor::_template_generate_open() { + template_generate_dialog->popup_file_dialog(); } -void LocalizationEditor::_pot_add_builtin_toggled() { - ProjectSettings::get_singleton()->set_setting("internationalization/locale/translation_add_builtin_strings_to_pot", translation_pot_add_builtin->is_pressed()); +void LocalizationEditor::_template_add_builtin_toggled() { + ProjectSettings::get_singleton()->set_setting("internationalization/locale/translation_add_builtin_strings_to_pot", template_add_builtin->is_pressed()); ProjectSettings::get_singleton()->save(); + + const PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); + if (sources.is_empty()) { + template_generate_button->set_disabled(!template_add_builtin->is_pressed()); + } } -void LocalizationEditor::_pot_generate(const String &p_file) { +void LocalizationEditor::_template_generate(const String &p_file) { EditorSettings::get_singleton()->set_project_metadata("pot_generator", "last_pot_path", p_file); - POTGenerator::get_singleton()->generate_pot(p_file); + TranslationTemplateGenerator::get_singleton()->generate(p_file); } -void LocalizationEditor::_update_pot_file_extensions() { - pot_file_open_dialog->clear_filters(); +void LocalizationEditor::_update_template_source_file_extensions() { + template_source_open_dialog->clear_filters(); List translation_parse_file_extensions; EditorTranslationParser::get_singleton()->get_recognized_extensions(&translation_parse_file_extensions); for (const String &E : translation_parse_file_extensions) { - pot_file_open_dialog->add_filter("*." + E); + template_source_open_dialog->add_filter("*." + E); } } @@ -426,15 +432,15 @@ void LocalizationEditor::connect_filesystem_dock_signals(FileSystemDock *p_fs_do } void LocalizationEditor::_filesystem_files_moved(const String &p_old_file, const String &p_new_file) { - // Update POT files if the moved file is a part of them. - PackedStringArray pot_translations = GLOBAL_GET("internationalization/locale/translations_pot_files"); - if (pot_translations.has(p_old_file)) { - pot_translations.erase(p_old_file); - ProjectSettings::get_singleton()->set_setting("internationalization/locale/translations_pot_files", pot_translations); + // Update source files if the moved file is a part of them. + PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); + if (sources.has(p_old_file)) { + sources.erase(p_old_file); + ProjectSettings::get_singleton()->set_setting("internationalization/locale/translations_pot_files", sources); PackedStringArray new_file; new_file.push_back(p_new_file); - _pot_add(new_file); + _template_source_add(new_file); } // Update remaps if the moved file is a part of them. @@ -488,11 +494,11 @@ void LocalizationEditor::_filesystem_files_moved(const String &p_old_file, const } void LocalizationEditor::_filesystem_file_removed(const String &p_file) { - // Check if the POT files are affected. - PackedStringArray pot_translations = GLOBAL_GET("internationalization/locale/translations_pot_files"); - if (pot_translations.has(p_file)) { - pot_translations.erase(p_file); - ProjectSettings::get_singleton()->set_setting("internationalization/locale/translations_pot_files", pot_translations); + // Check if the source files are affected. + PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); + if (sources.has(p_file)) { + sources.erase(p_file); + ProjectSettings::get_singleton()->set_setting("internationalization/locale/translations_pot_files", sources); } // Check if the remaps are affected. @@ -701,24 +707,24 @@ void LocalizationEditor::update_translations() { } } - // Update translation POT files. - translation_pot_list->clear(); - root = translation_pot_list->create_item(nullptr); - translation_pot_list->set_hide_root(true); - PackedStringArray pot_translations = GLOBAL_GET("internationalization/locale/translations_pot_files"); - for (int i = 0; i < pot_translations.size(); i++) { - TreeItem *t = translation_pot_list->create_item(root); + // Update translation source files. + template_source_list->clear(); + root = template_source_list->create_item(nullptr); + template_source_list->set_hide_root(true); + PackedStringArray sources = GLOBAL_GET("internationalization/locale/translations_pot_files"); + for (int i = 0; i < sources.size(); i++) { + TreeItem *t = template_source_list->create_item(root); t->set_editable(0, false); - t->set_text(0, pot_translations[i].replace_first("res://", "")); - t->set_tooltip_text(0, pot_translations[i]); + t->set_text(0, sources[i].replace_first("res://", "")); + t->set_tooltip_text(0, sources[i]); t->set_metadata(0, i); t->add_button(0, get_editor_theme_icon(SNAME("Remove")), 0, false, TTRC("Remove")); } - // New translation parser plugin might extend possible file extensions in POT generation. - _update_pot_file_extensions(); + // New translation parser plugin might extend possible file extensions in template generation. + _update_template_source_file_extensions(); - pot_generate_button->set_disabled(pot_translations.is_empty()); + template_generate_button->set_disabled(sources.is_empty() && !template_add_builtin->is_pressed()); updating_translations = false; } @@ -844,7 +850,7 @@ LocalizationEditor::LocalizationEditor() { { VBoxContainer *tvb = memnew(VBoxContainer); - tvb->set_name(TTRC("POT Generation")); + tvb->set_name(TTRC("Template Generation")); translations->add_child(tvb); HBoxContainer *thb = memnew(HBoxContainer); @@ -855,35 +861,35 @@ LocalizationEditor::LocalizationEditor() { tvb->add_child(thb); Button *addtr = memnew(Button(TTRC("Add..."))); - addtr->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_pot_file_open)); + addtr->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_template_source_file_open)); thb->add_child(addtr); - pot_generate_button = memnew(Button(TTRC("Generate POT"))); - pot_generate_button->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_pot_generate_open)); - thb->add_child(pot_generate_button); + template_generate_button = memnew(Button(TTRC("Generate"))); + template_generate_button->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_template_generate_open)); + thb->add_child(template_generate_button); - translation_pot_list = memnew(Tree); - translation_pot_list->set_v_size_flags(Control::SIZE_EXPAND_FILL); - tvb->add_child(translation_pot_list); - trees.push_back(translation_pot_list); - tree_data_types[translation_pot_list] = "localization_editor_pot_item"; - tree_settings[translation_pot_list] = "internationalization/locale/translations_pot_files"; + template_source_list = memnew(Tree); + template_source_list->set_v_size_flags(Control::SIZE_EXPAND_FILL); + tvb->add_child(template_source_list); + trees.push_back(template_source_list); + tree_data_types[template_source_list] = "localization_editor_pot_item"; + tree_settings[template_source_list] = "internationalization/locale/translations_pot_files"; - translation_pot_add_builtin = memnew(CheckBox(TTRC("Add Built-in Strings to POT"))); - translation_pot_add_builtin->set_tooltip_text(TTRC("Add strings from built-in components such as certain Control nodes.")); - translation_pot_add_builtin->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_pot_add_builtin_toggled)); - tvb->add_child(translation_pot_add_builtin); + template_add_builtin = memnew(CheckBox(TTRC("Add Built-in Strings"))); + template_add_builtin->set_tooltip_text(TTRC("Add strings from built-in components such as certain Control nodes.")); + template_add_builtin->connect(SceneStringName(pressed), callable_mp(this, &LocalizationEditor::_template_add_builtin_toggled)); + tvb->add_child(template_add_builtin); - pot_generate_dialog = memnew(EditorFileDialog); - pot_generate_dialog->set_file_mode(EditorFileDialog::FILE_MODE_SAVE_FILE); - pot_generate_dialog->set_current_path(EditorSettings::get_singleton()->get_project_metadata("pot_generator", "last_pot_path", String())); - pot_generate_dialog->connect("file_selected", callable_mp(this, &LocalizationEditor::_pot_generate)); - add_child(pot_generate_dialog); + template_generate_dialog = memnew(EditorFileDialog); + template_generate_dialog->set_file_mode(EditorFileDialog::FILE_MODE_SAVE_FILE); + template_generate_dialog->set_current_path(EditorSettings::get_singleton()->get_project_metadata("pot_generator", "last_pot_path", String())); + template_generate_dialog->connect("file_selected", callable_mp(this, &LocalizationEditor::_template_generate)); + add_child(template_generate_dialog); - pot_file_open_dialog = memnew(EditorFileDialog); - pot_file_open_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILES); - pot_file_open_dialog->connect("files_selected", callable_mp(this, &LocalizationEditor::_pot_add)); - add_child(pot_file_open_dialog); + template_source_open_dialog = memnew(EditorFileDialog); + template_source_open_dialog->set_file_mode(EditorFileDialog::FILE_MODE_OPEN_FILES); + template_source_open_dialog->connect("files_selected", callable_mp(this, &LocalizationEditor::_template_source_add)); + add_child(template_source_open_dialog); } for (Tree *tree : trees) { diff --git a/editor/translations/localization_editor.h b/editor/translations/localization_editor.h index f19e29609af..e7c1b80420e 100644 --- a/editor/translations/localization_editor.h +++ b/editor/translations/localization_editor.h @@ -51,11 +51,11 @@ class LocalizationEditor : public VBoxContainer { Tree *translation_remap = nullptr; Tree *translation_remap_options = nullptr; - Tree *translation_pot_list = nullptr; - CheckBox *translation_pot_add_builtin = nullptr; - EditorFileDialog *pot_file_open_dialog = nullptr; - EditorFileDialog *pot_generate_dialog = nullptr; - Button *pot_generate_button = nullptr; + Tree *template_source_list = nullptr; + CheckBox *template_add_builtin = nullptr; + EditorFileDialog *template_source_open_dialog = nullptr; + EditorFileDialog *template_generate_dialog = nullptr; + Button *template_generate_button = nullptr; bool updating_translations = false; String localization_changed; @@ -79,13 +79,13 @@ class LocalizationEditor : public VBoxContainer { void _translation_res_option_popup(bool p_arrow_clicked); void _translation_res_option_selected(const String &p_locale); - void _pot_add(const PackedStringArray &p_paths); - void _pot_delete(Object *p_item, int p_column, int p_button, MouseButton p_mouse_button); - void _pot_file_open(); - void _pot_generate_open(); - void _pot_add_builtin_toggled(); - void _pot_generate(const String &p_file); - void _update_pot_file_extensions(); + void _template_source_add(const PackedStringArray &p_paths); + void _template_source_delete(Object *p_item, int p_column, int p_button, MouseButton p_mouse_button); + void _template_source_file_open(); + void _template_generate_open(); + void _template_add_builtin_toggled(); + void _template_generate(const String &p_file); + void _update_template_source_file_extensions(); void _filesystem_files_moved(const String &p_old_file, const String &p_new_file); void _filesystem_file_removed(const String &p_file); diff --git a/editor/translations/pot_generator.cpp b/editor/translations/pot_generator.cpp deleted file mode 100644 index b4aa8977d90..00000000000 --- a/editor/translations/pot_generator.cpp +++ /dev/null @@ -1,260 +0,0 @@ -/**************************************************************************/ -/* pot_generator.cpp */ -/**************************************************************************/ -/* This file is part of: */ -/* GODOT ENGINE */ -/* https://godotengine.org */ -/**************************************************************************/ -/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ -/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ -/* */ -/* Permission is hereby granted, free of charge, to any person obtaining */ -/* a copy of this software and associated documentation files (the */ -/* "Software"), to deal in the Software without restriction, including */ -/* without limitation the rights to use, copy, modify, merge, publish, */ -/* distribute, sublicense, and/or sell copies of the Software, and to */ -/* permit persons to whom the Software is furnished to do so, subject to */ -/* the following conditions: */ -/* */ -/* The above copyright notice and this permission notice shall be */ -/* included in all copies or substantial portions of the Software. */ -/* */ -/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ -/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ -/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ -/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ -/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ -/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ -/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/**************************************************************************/ - -#include "pot_generator.h" - -#include "core/config/project_settings.h" -#include "core/error/error_macros.h" -#include "editor/translations/editor_translation.h" -#include "editor/translations/editor_translation_parser.h" - -POTGenerator *POTGenerator::singleton = nullptr; - -#ifdef DEBUG_POT -void POTGenerator::_print_all_translation_strings() { - for (HashMap>::Element E = all_translation_strings.front(); E; E = E.next()) { - Vector v_md = all_translation_strings[E.key()]; - for (int i = 0; i < v_md.size(); i++) { - print_line("++++++"); - print_line("msgid: " + E.key()); - print_line("context: " + v_md[i].ctx); - print_line("msgid_plural: " + v_md[i].plural); - for (const String &F : v_md[i].locations) { - print_line("location: " + F); - } - } - } -} -#endif - -void POTGenerator::generate_pot(const String &p_file) { - Vector files = GLOBAL_GET("internationalization/locale/translations_pot_files"); - - if (files.is_empty()) { - WARN_PRINT("No files selected for POT generation."); - return; - } - - // Clear all_translation_strings of the previous round. - all_translation_strings.clear(); - - // Collect all translatable strings according to files order in "POT Generation" setting. - for (int i = 0; i < files.size(); i++) { - Vector> translations; - - const String &file_path = files[i]; - String file_extension = file_path.get_extension(); - - if (EditorTranslationParser::get_singleton()->can_parse(file_extension)) { - EditorTranslationParser::get_singleton()->get_parser(file_extension)->parse_file(file_path, &translations); - } else { - ERR_PRINT("Unrecognized file extension " + file_extension + " in generate_pot()"); - return; - } - - for (const Vector &translation : translations) { - ERR_CONTINUE(translation.is_empty()); - const String &msgctxt = (translation.size() > 1) ? translation[1] : String(); - const String &msgid_plural = (translation.size() > 2) ? translation[2] : String(); - const String &comment = (translation.size() > 3) ? translation[3] : String(); - const int source_line = (translation.size() > 4) ? translation[4].to_int() : 0; - String location = file_path; - if (source_line > 0) { - location += vformat(":%d", source_line); - } - _add_new_msgid(translation[0], msgctxt, msgid_plural, location, comment); - } - } - - if (GLOBAL_GET("internationalization/locale/translation_add_builtin_strings_to_pot")) { - for (const Vector &extractable_msgids : get_extractable_message_list()) { - _add_new_msgid(extractable_msgids[0], extractable_msgids[1], extractable_msgids[2], "", ""); - } - } - - _write_to_pot(p_file); -} - -void POTGenerator::_write_to_pot(const String &p_file) { - Error err; - Ref file = FileAccess::open(p_file, FileAccess::WRITE, &err); - if (err != OK) { - ERR_PRINT("Failed to open " + p_file); - return; - } - - String project_name = GLOBAL_GET("application/config/name").operator String().replace("\n", "\\n"); - Vector files = GLOBAL_GET("internationalization/locale/translations_pot_files"); - String extracted_files = ""; - for (int i = 0; i < files.size(); i++) { - extracted_files += "# " + files[i].replace("\n", "\\n") + "\n"; - } - const String header = - "# LANGUAGE translation for " + project_name + " for the following files:\n" + - extracted_files + - "#\n" - "# FIRST AUTHOR , YEAR.\n" - "#\n" - "#, fuzzy\n" - "msgid \"\"\n" - "msgstr \"\"\n" - "\"Project-Id-Version: " + - project_name + - "\\n\"\n" - "\"MIME-Version: 1.0\\n\"\n" - "\"Content-Type: text/plain; charset=UTF-8\\n\"\n" - "\"Content-Transfer-Encoding: 8-bit\\n\"\n"; - - file->store_string(header); - - for (const KeyValue> &E_pair : all_translation_strings) { - String msgid = E_pair.key; - const Vector &v_msgid_data = E_pair.value; - for (int i = 0; i < v_msgid_data.size(); i++) { - String context = v_msgid_data[i].ctx; - String plural = v_msgid_data[i].plural; - const HashSet &locations = v_msgid_data[i].locations; - const HashSet &comments = v_msgid_data[i].comments; - - // Put the blank line at the start, to avoid a double at the end when closing the file. - file->store_line(""); - - // Write comments. - bool is_first_comment = true; - for (const String &E : comments) { - if (is_first_comment) { - file->store_line("#. TRANSLATORS: " + E.replace("\n", "\n#. ")); - } else { - file->store_line("#. " + E.replace("\n", "\n#. ")); - } - is_first_comment = false; - } - - // Write file locations. - for (const String &E : locations) { - file->store_line("#: " + E.trim_prefix("res://").replace("\n", "\\n")); - } - - // Write context. - if (!context.is_empty()) { - file->store_line("msgctxt " + context.json_escape().quote()); - } - - // Write msgid. - _write_msgid(file, msgid, false); - - // Write msgid_plural. - if (!plural.is_empty()) { - _write_msgid(file, plural, true); - file->store_line("msgstr[0] \"\""); - file->store_line("msgstr[1] \"\""); - } else { - file->store_line("msgstr \"\""); - } - } - } -} - -void POTGenerator::_write_msgid(Ref r_file, const String &p_id, bool p_plural) { - if (p_plural) { - r_file->store_string("msgid_plural "); - } else { - r_file->store_string("msgid "); - } - - if (p_id.is_empty()) { - r_file->store_line("\"\""); - return; - } - - const Vector lines = p_id.split("\n"); - const String &last_line = lines[lines.size() - 1]; // `lines` cannot be empty. - int pot_line_count = lines.size(); - if (last_line.is_empty()) { - pot_line_count--; - } - - if (pot_line_count > 1) { - r_file->store_line("\"\""); - } - - for (int i = 0; i < lines.size() - 1; i++) { - r_file->store_line((lines[i] + "\n").json_escape().quote()); - } - - if (!last_line.is_empty()) { - r_file->store_line(last_line.json_escape().quote()); - } -} - -void POTGenerator::_add_new_msgid(const String &p_msgid, const String &p_context, const String &p_plural, const String &p_location, const String &p_comment) { - // Insert new location if msgid under same context exists already. - if (all_translation_strings.has(p_msgid)) { - Vector &v_mdata = all_translation_strings[p_msgid]; - for (int i = 0; i < v_mdata.size(); i++) { - if (v_mdata[i].ctx == p_context) { - if (!v_mdata[i].plural.is_empty() && !p_plural.is_empty() && v_mdata[i].plural != p_plural) { - WARN_PRINT("Redefinition of plural message (msgid_plural), under the same message (msgid) and context (msgctxt)"); - } - if (!p_location.is_empty()) { - v_mdata.write[i].locations.insert(p_location); - } - if (!p_comment.is_empty()) { - v_mdata.write[i].comments.insert(p_comment); - } - return; - } - } - } - - // Add a new entry. - MsgidData mdata; - mdata.ctx = p_context; - mdata.plural = p_plural; - if (!p_location.is_empty()) { - mdata.locations.insert(p_location); - } - if (!p_comment.is_empty()) { - mdata.comments.insert(p_comment); - } - all_translation_strings[p_msgid].push_back(mdata); -} - -POTGenerator *POTGenerator::get_singleton() { - if (!singleton) { - singleton = memnew(POTGenerator); - } - return singleton; -} - -POTGenerator::~POTGenerator() { - memdelete(singleton); - singleton = nullptr; -} diff --git a/editor/translations/template_generator.cpp b/editor/translations/template_generator.cpp new file mode 100644 index 00000000000..7b9c1566537 --- /dev/null +++ b/editor/translations/template_generator.cpp @@ -0,0 +1,285 @@ +/**************************************************************************/ +/* template_generator.cpp */ +/**************************************************************************/ +/* This file is part of: */ +/* GODOT ENGINE */ +/* https://godotengine.org */ +/**************************************************************************/ +/* Copyright (c) 2014-present Godot Engine contributors (see AUTHORS.md). */ +/* Copyright (c) 2007-2014 Juan Linietsky, Ariel Manzur. */ +/* */ +/* Permission is hereby granted, free of charge, to any person obtaining */ +/* a copy of this software and associated documentation files (the */ +/* "Software"), to deal in the Software without restriction, including */ +/* without limitation the rights to use, copy, modify, merge, publish, */ +/* distribute, sublicense, and/or sell copies of the Software, and to */ +/* permit persons to whom the Software is furnished to do so, subject to */ +/* the following conditions: */ +/* */ +/* The above copyright notice and this permission notice shall be */ +/* included in all copies or substantial portions of the Software. */ +/* */ +/* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, */ +/* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF */ +/* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. */ +/* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY */ +/* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, */ +/* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE */ +/* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +/**************************************************************************/ + +#include "template_generator.h" + +#include "core/config/project_settings.h" +#include "editor/translations/editor_translation.h" +#include "editor/translations/editor_translation_parser.h" + +TranslationTemplateGenerator::MessageMap TranslationTemplateGenerator::parse(const Vector &p_sources, bool p_add_builtin) const { + Vector> raw; + + for (const String &path : p_sources) { + Vector> parsed_from_file; + + const String &extension = path.get_extension(); + ERR_CONTINUE_MSG(!EditorTranslationParser::get_singleton()->can_parse(extension), vformat("Cannot parse file '%s': unrecognized file extension. Skipping.", path)); + + EditorTranslationParser::get_singleton()->get_parser(extension)->parse_file(path, &parsed_from_file); + + for (const Vector &entry : parsed_from_file) { + ERR_CONTINUE(entry.is_empty()); + + const String &msgctxt = (entry.size() > 1) ? entry[1] : String(); + const String &msgid_plural = (entry.size() > 2) ? entry[2] : String(); + const String &comment = (entry.size() > 3) ? entry[3] : String(); + const int source_line = (entry.size() > 4) ? entry[4].to_int() : 0; + const String &location = source_line > 0 ? vformat("%s:%d", path, source_line) : path; + + raw.push_back({ entry[0], msgctxt, msgid_plural, comment, location }); + } + } + + if (p_add_builtin) { + for (const Vector &extractable_msgids : get_extractable_message_list()) { + raw.push_back({ extractable_msgids[0], extractable_msgids[1], extractable_msgids[2], String(), String() }); + } + } + + MessageMap result; + for (const Vector &entry : raw) { + const String &msgid = entry[0]; + const String &msgctxt = entry[1]; + const String &plural = entry[2]; + const String &comment = entry[3]; + const String &location = entry[4]; + + const Translation::MessageKey key = { msgctxt, msgid }; + MessageData &mdata = result[key]; + if (!mdata.plural.is_empty() && !plural.is_empty() && mdata.plural != plural) { + WARN_PRINT(vformat(R"(Skipping different plural definitions for msgid "%s" msgctxt "%s": "%s" and "%s")", msgid, msgctxt, mdata.plural, plural)); + continue; + } + mdata.plural = plural; + if (!location.is_empty()) { + mdata.locations.insert(location); + } + if (!comment.is_empty()) { + mdata.comments.insert(comment); + } + } + return result; +} + +void TranslationTemplateGenerator::generate(const String &p_file) { + const Vector files = GLOBAL_GET("internationalization/locale/translations_pot_files"); + const bool add_builtin = GLOBAL_GET("internationalization/locale/translation_add_builtin_strings_to_pot"); + + const MessageMap &map = parse(files, add_builtin); + if (map.is_empty()) { + WARN_PRINT("No translatable strings found."); + return; + } + + Error err; + Ref file = FileAccess::open(p_file, FileAccess::WRITE, &err); + ERR_FAIL_COND_MSG(err != OK, "Failed to open " + p_file); + + const String ext = p_file.get_extension().to_lower(); + if (ext == "pot") { + _write_to_pot(file, map); + } else if (ext == "csv") { + _write_to_csv(file, map); + } else { + ERR_FAIL_MSG("Unrecognized translation template file extension: " + ext); + } +} + +static void _write_pot_field(Ref p_file, const String &p_name, const String &p_value) { + p_file->store_string(p_name + " "); + + if (p_value.is_empty()) { + p_file->store_line("\"\""); + return; + } + + const Vector lines = p_value.split("\n"); + DEV_ASSERT(lines.size() > 0); + + const String &last_line = lines[lines.size() - 1]; + const int pot_line_count = last_line.is_empty() ? lines.size() - 1 : lines.size(); + + if (pot_line_count > 1) { + p_file->store_line("\"\""); + } + + for (int i = 0; i < lines.size() - 1; i++) { + p_file->store_line((lines[i] + "\n").json_escape().quote()); + } + if (!last_line.is_empty()) { + p_file->store_line(last_line.json_escape().quote()); + } +} + +void TranslationTemplateGenerator::_write_to_pot(Ref p_file, const MessageMap &p_map) const { + const String project_name = GLOBAL_GET("application/config/name").operator String().replace("\n", "\\n"); + const Vector files = GLOBAL_GET("internationalization/locale/translations_pot_files"); + String extracted_files; + for (const String &file : files) { + extracted_files += "# " + file.replace("\n", "\\n") + "\n"; + } + const String header = + "# LANGUAGE translation for " + project_name + " for the following files:\n" + + extracted_files + + "#\n" + "# FIRST AUTHOR , YEAR.\n" + "#\n" + "#, fuzzy\n" + "msgid \"\"\n" + "msgstr \"\"\n" + "\"Project-Id-Version: " + + project_name + + "\\n\"\n" + "\"MIME-Version: 1.0\\n\"\n" + "\"Content-Type: text/plain; charset=UTF-8\\n\"\n" + "\"Content-Transfer-Encoding: 8-bit\\n\"\n"; + p_file->store_string(header); + + for (const KeyValue &E : p_map) { + // Put the blank line at the start, to avoid a double at the end when closing the file. + p_file->store_line(""); + + // Write comments. + bool is_first_comment = true; + for (const String &comment : E.value.comments) { + if (is_first_comment) { + p_file->store_line("#. TRANSLATORS: " + comment.replace("\n", "\n#. ")); + } else { + p_file->store_line("#. " + comment.replace("\n", "\n#. ")); + } + is_first_comment = false; + } + + // Write file locations. + for (const String &location : E.value.locations) { + p_file->store_line("#: " + location.trim_prefix("res://").replace("\n", "\\n")); + } + + // Write context. + const String msgctxt = E.key.msgctxt; + if (!msgctxt.is_empty()) { + p_file->store_line("msgctxt " + msgctxt.json_escape().quote()); + } + + // Write msgid. + _write_pot_field(p_file, "msgid", E.key.msgid); + + // Write msgid_plural. + if (E.value.plural.is_empty()) { + p_file->store_line("msgstr \"\""); + } else { + _write_pot_field(p_file, "msgid_plural", E.value.plural); + p_file->store_line("msgstr[0] \"\""); + p_file->store_line("msgstr[1] \"\""); + } + } +} + +static String _join_strings(const HashSet &p_strings) { + String result; + bool is_first = true; + for (const String &s : p_strings) { + if (!is_first) { + result += '\n'; + } + result += s; + is_first = false; + } + return result; +} + +void TranslationTemplateGenerator::_write_to_csv(Ref p_file, const MessageMap &p_map) const { + // Avoid adding unnecessary columns. + bool context_used = false; + bool plural_used = false; + bool comments_used = false; + bool locations_used = false; + { + for (const KeyValue &E : p_map) { + if (!context_used && !E.key.msgctxt.is_empty()) { + context_used = true; + } + if (!plural_used && !E.value.plural.is_empty()) { + plural_used = true; + } + if (!comments_used && !E.value.comments.is_empty()) { + comments_used = true; + } + if (!locations_used && !E.value.locations.is_empty()) { + locations_used = true; + } + } + } + + Vector header = { "key" }; + if (context_used) { + header.push_back("?context"); + } + if (plural_used) { + header.push_back("?plural"); + } + if (comments_used) { + header.push_back("_comments"); + } + if (locations_used) { + header.push_back("_locations"); + } + p_file->store_csv_line(header); + + for (const KeyValue &E : p_map) { + Vector line = { E.key.msgid }; + if (context_used) { + line.push_back(E.key.msgctxt); + } + if (plural_used) { + line.push_back(E.value.plural); + } + if (comments_used) { + line.push_back(_join_strings(E.value.comments)); + } + if (locations_used) { + line.push_back(_join_strings(E.value.locations)); + } + p_file->store_csv_line(line); + } +} + +TranslationTemplateGenerator *TranslationTemplateGenerator::get_singleton() { + if (!singleton) { + singleton = memnew(TranslationTemplateGenerator); + } + return singleton; +} + +TranslationTemplateGenerator::~TranslationTemplateGenerator() { + memdelete(singleton); + singleton = nullptr; +} diff --git a/editor/translations/pot_generator.h b/editor/translations/template_generator.h similarity index 72% rename from editor/translations/pot_generator.h rename to editor/translations/template_generator.h index 56f5de0a207..75703c6b2dc 100644 --- a/editor/translations/pot_generator.h +++ b/editor/translations/template_generator.h @@ -1,5 +1,5 @@ /**************************************************************************/ -/* pot_generator.h */ +/* template_generator.h */ /**************************************************************************/ /* This file is part of: */ /* GODOT ENGINE */ @@ -31,34 +31,28 @@ #pragma once #include "core/io/file_access.h" -#include "core/templates/hash_map.h" -#include "core/templates/hash_set.h" +#include "core/string/translation.h" -//#define DEBUG_POT +class TranslationTemplateGenerator { + static inline TranslationTemplateGenerator *singleton = nullptr; -class POTGenerator { - static POTGenerator *singleton; - - struct MsgidData { - String ctx; + struct MessageData { String plural; HashSet locations; HashSet comments; }; - // Store msgid as key and the additional data around the msgid - if it's under a context, has plurals and its file locations. - HashMap> all_translation_strings; - void _write_to_pot(const String &p_file); - void _write_msgid(Ref r_file, const String &p_id, bool p_plural); - void _add_new_msgid(const String &p_msgid, const String &p_context, const String &p_plural, const String &p_location, const String &p_comment); + using MessageMap = HashMap; -#ifdef DEBUG_POT - void _print_all_translation_strings(); -#endif + MessageMap parse(const Vector &p_sources, bool p_add_builtin) const; + + void _write_to_pot(Ref p_file, const MessageMap &p_map) const; + void _write_to_csv(Ref p_file, const MessageMap &p_map) const; public: - static POTGenerator *get_singleton(); - void generate_pot(const String &p_file); + static TranslationTemplateGenerator *get_singleton(); - ~POTGenerator(); + void generate(const String &p_file); + + ~TranslationTemplateGenerator(); };