Quellcode durchsuchen

Add CSV translation template generation

Haoyu Qiu vor 1 Monat
Ursprung
Commit
ea9a2c3b2c

+ 4 - 2
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, Vector<StringName>, MessageKey> translation_map;
 
 	mutable PluralRules *plural_rules_cache = nullptr;

+ 2 - 2
doc/classes/EditorTranslationParserPlugin.xml

@@ -6,8 +6,8 @@
 	<description>
 		[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

+ 2 - 2
doc/classes/Node.xml

@@ -1037,7 +1037,7 @@
 	</methods>
 	<members>
 		<member name="auto_translate_mode" type="int" setter="set_auto_translate_mode" getter="get_auto_translate_mode" enum="Node.AutoTranslateMode" default="0">
-			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].
 		</member>
 		<member name="editor_description" type="String" setter="set_editor_description" getter="get_editor_description" default="&quot;&quot;">
@@ -1397,7 +1397,7 @@
 		</constant>
 		<constant name="AUTO_TRANSLATE_MODE_DISABLED" value="2" enum="AutoTranslateMode">
 			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].
 		</constant>
 	</constants>
 </class>

+ 4 - 1
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.

+ 85 - 79
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<String> 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<String> 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);
-
-		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";
-
-		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);
-
-		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);
-
-		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_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);
+
+		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";
+
+		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);
+
+		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);
+
+		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) {

+ 12 - 12
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);

+ 0 - 260
editor/translations/pot_generator.cpp

@@ -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<String, Vector<POTGenerator::MsgidData>>::Element E = all_translation_strings.front(); E; E = E.next()) {
-		Vector<MsgidData> 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<String> 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<Vector<String>> 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<String> &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<String> &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<FileAccess> 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<String> 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 <EMAIL@ADDRESS>, 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<String, Vector<MsgidData>> &E_pair : all_translation_strings) {
-		String msgid = E_pair.key;
-		const Vector<MsgidData> &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<String> &locations = v_msgid_data[i].locations;
-			const HashSet<String> &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<FileAccess> 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<String> 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<MsgidData> &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;
-}

+ 285 - 0
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<String> &p_sources, bool p_add_builtin) const {
+	Vector<Vector<String>> raw;
+
+	for (const String &path : p_sources) {
+		Vector<Vector<String>> 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<String> &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<String> &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<String> &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<String> 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<FileAccess> 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<FileAccess> 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<String> 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<FileAccess> p_file, const MessageMap &p_map) const {
+	const String project_name = GLOBAL_GET("application/config/name").operator String().replace("\n", "\\n");
+	const Vector<String> 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 <EMAIL@ADDRESS>, 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<Translation::MessageKey, MessageData> &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<String> &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<FileAccess> 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<Translation::MessageKey, MessageData> &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<String> 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<Translation::MessageKey, MessageData> &E : p_map) {
+		Vector<String> 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;
+}

+ 14 - 20
editor/translations/pot_generator.h → 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<String> locations;
 		HashSet<String> 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<String, Vector<MsgidData>> all_translation_strings;
 
-	void _write_to_pot(const String &p_file);
-	void _write_msgid(Ref<FileAccess> 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<Translation::MessageKey, MessageData, Translation::MessageKey>;
+
+	MessageMap parse(const Vector<String> &p_sources, bool p_add_builtin) const;
 
-#ifdef DEBUG_POT
-	void _print_all_translation_strings();
-#endif
+	void _write_to_pot(Ref<FileAccess> p_file, const MessageMap &p_map) const;
+	void _write_to_csv(Ref<FileAccess> p_file, const MessageMap &p_map) const;
 
 public:
-	static POTGenerator *get_singleton();
-	void generate_pot(const String &p_file);
+	static TranslationTemplateGenerator *get_singleton();
+
+	void generate(const String &p_file);
 
-	~POTGenerator();
+	~TranslationTemplateGenerator();
 };