Skip to content

Commit 0f3b2ea

Browse files
committed
Implement gettext translation file generation
1 parent 7f1b837 commit 0f3b2ea

File tree

10 files changed

+716
-614
lines changed

10 files changed

+716
-614
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
class_name DialogicTranslationCsvFile
2+
extends DialogicTranslationFile
3+
## Generates translation files in CSV format.
4+
5+
var lines: Array[PackedStringArray] = []
6+
## Dictionary of lines from the original file.
7+
## Key: String, Value: PackedStringArray
8+
var old_lines: Dictionary = {}
9+
10+
## The amount of columns the CSV file has after loading it.
11+
## Used to add trailing commas to new lines.
12+
var column_count := 0
13+
14+
## The underlying file used to read and write the CSV file.
15+
var file: FileAccess
16+
17+
## Whether this CSV handler should add newlines as a separator between sections.
18+
## A section may be a new character, new timeline, or new glossary item inside
19+
## a per-project file.
20+
var add_separator: bool = false
21+
22+
23+
## Attempts to load the CSV file from [param file_path].
24+
## If the file does not exist, a single entry is added to the [member lines]
25+
## array.
26+
## The [param separator_enabled] enables adding newlines as a separator to
27+
## per-project files. This is useful for readability.
28+
func _init(file_path: String, original_locale: String, separator_enabled: bool) -> void:
29+
super._init(file_path, original_locale)
30+
31+
add_separator = separator_enabled
32+
33+
# The first entry must be the locale row.
34+
# [method collect_lines_from_timeline] will add the other locales, if any.
35+
var locale_array_line := PackedStringArray(["keys", original_locale])
36+
lines.append(locale_array_line)
37+
38+
if is_new_file:
39+
# The "keys" and original locale are the only columns in a new file.
40+
# For example: "keys, en"
41+
column_count = 2
42+
return
43+
44+
file = FileAccess.open(file_path, FileAccess.READ)
45+
46+
var locale_csv_row := file.get_csv_line()
47+
column_count = locale_csv_row.size()
48+
var locale_key := locale_csv_row[0]
49+
50+
old_lines[locale_key] = locale_csv_row
51+
52+
_read_file_into_lines()
53+
54+
55+
## Private function to read the CSV file into the [member lines] array.
56+
## Cannot be called on a new file.
57+
func _read_file_into_lines() -> void:
58+
while not file.eof_reached():
59+
var line := file.get_csv_line()
60+
var row_key := line[0]
61+
62+
old_lines[row_key] = line
63+
64+
65+
func _append(key: String, value: String, _path: String, _line_number: int = -1) -> void:
66+
var array_line := PackedStringArray([key, value])
67+
lines.append(array_line)
68+
69+
70+
## Appends an empty line to the [member lines] array.
71+
func _append_separator() -> void:
72+
if add_separator:
73+
var empty_line := PackedStringArray(["", ""])
74+
lines.append(empty_line)
75+
76+
77+
## Clears the CSV file on disk and writes the current [member lines] array to it.
78+
## Uses the [member old_lines] dictionary to update existing translations.
79+
## If a translation row misses a column, a trailing comma will be added to
80+
## conform to the CSV file format.
81+
##
82+
## If the locale CSV line was collected only, a new file won't be created and
83+
## already existing translations won't be updated.
84+
func update_file_on_disk() -> void:
85+
# None or locale row only.
86+
if lines.size() < 2:
87+
print_rich("[color=yellow]No lines for the CSV file, skipping: " + used_file_path)
88+
89+
return
90+
91+
# Clear the current CSV file.
92+
file = FileAccess.open(used_file_path, FileAccess.WRITE)
93+
94+
for line in lines:
95+
var row_key := line[0]
96+
97+
# In case there might be translations for this line already,
98+
# add them at the end again (orig locale text is replaced).
99+
if row_key in old_lines:
100+
var old_line: PackedStringArray = old_lines[row_key]
101+
var updated_line: PackedStringArray = line + old_line.slice(2)
102+
103+
var line_columns: int = updated_line.size()
104+
var line_columns_to_add := column_count - line_columns
105+
106+
# Add trailing commas to match the amount of columns.
107+
for _i in range(line_columns_to_add):
108+
updated_line.append("")
109+
110+
file.store_csv_line(updated_line)
111+
updated_rows += 1
112+
113+
else:
114+
var line_columns: int = line.size()
115+
var line_columns_to_add := column_count - line_columns
116+
117+
# Add trailing commas to match the amount of columns.
118+
for _i in range(line_columns_to_add):
119+
line.append("")
120+
121+
file.store_csv_line(line)
122+
new_rows += 1
123+
124+
file.close()
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
class_name DialogicTranslationGettextFile
2+
extends DialogicTranslationFile
3+
## Generates translation files in gettext format.
4+
5+
var translations: Array[PotEntry] = []
6+
7+
## Configured original locale.
8+
var original_locale: String
9+
10+
## Locations of the source files included in this translation.
11+
var locations: Array[String] = []
12+
13+
14+
## There is no need to load the old file(s) here, because every locale has its own file
15+
## and this class doens't touch them.
16+
func _init(file_path: String, original_locale: String) -> void:
17+
super._init(file_path, original_locale)
18+
self.original_locale = original_locale
19+
20+
21+
func _append(key: String, value: String, path: String, line_number: int = -1) -> void:
22+
var entry = PotEntry.new()
23+
entry.key = key
24+
entry.translation = value
25+
entry.locations.append(PotReference.new(path, line_number))
26+
translations.append(entry)
27+
28+
29+
## gettext doesn't support separators so this is a no-op.
30+
func _append_separator() -> void:
31+
pass
32+
33+
34+
## Overwrites the .pot file and the .po file of the original locale with the current [member translations] array.
35+
func update_file_on_disk() -> void:
36+
# Overwrite the POT file.
37+
var file = FileAccess.open(used_file_path, FileAccess.WRITE)
38+
_write_header(file)
39+
for entry in translations:
40+
_write_entry(file, entry, "")
41+
file.close()
42+
43+
# Overwrite the original_locale PO file.
44+
file = FileAccess.open(used_file_path.trim_suffix(".pot") + "." + original_locale + ".po", FileAccess.WRITE)
45+
_write_header(file, original_locale)
46+
for entry in translations:
47+
_write_entry(file, entry)
48+
file.close()
49+
50+
51+
# This is based on POTGenerator::_write_to_pot() which unfortunately isn't exposed to gdscript.
52+
func _write_header(file: FileAccess, locale: String = "") -> void:
53+
var project_name = ProjectSettings.get("application/config/name");
54+
var language_header = locale if !locale.is_empty() else "LANGUAGE"
55+
file.store_line("# " + language_header + " translation for " + project_name + " for the following files:")
56+
57+
locations.sort()
58+
for location in locations:
59+
file.store_line("# " + location)
60+
61+
file.store_line("")
62+
file.store_line("#, fuzzy");
63+
file.store_line("msgid \"\"")
64+
file.store_line("msgstr \"\"")
65+
file.store_line("\"Project-Id-Version: " + project_name + "\\n\"")
66+
if !locale.is_empty():
67+
file.store_line("\"Language: " + locale + "\\n\"")
68+
file.store_line("\"MIME-Version: 1.0\\n\"")
69+
file.store_line("\"Content-Type: text/plain; charset=UTF-8\\n\"")
70+
file.store_line("\"Content-Transfer-Encoding: 8-bit\\n\"")
71+
72+
73+
func _write_entry(file: FileAccess, entry: PotEntry, value: String = entry.translation) -> void:
74+
file.store_line("")
75+
76+
entry.locations.sort_custom(func (a: String, b: String): return b > a)
77+
for location in entry.locations:
78+
file.store_line("#: " + location.as_str())
79+
80+
_write_line(file, "msgid", entry.key)
81+
_write_line(file, "msgstr", value)
82+
83+
84+
# This is based on POTGenerator::_write_msgid() which unfortunately isn't exposed to gdscript.
85+
func _write_line(file: FileAccess, type: String, value: String) -> void:
86+
file.store_string(type + " ")
87+
if value.is_empty():
88+
file.store_line("\"\"")
89+
return
90+
91+
var lines = value.split("\n")
92+
var last_line = lines[lines.size() - 1]
93+
var pot_line_count = lines.size()
94+
if last_line.is_empty():
95+
pot_line_count -= 1
96+
97+
if pot_line_count > 1:
98+
file.store_line("\"\"")
99+
100+
for i in range(0, lines.size() - 1):
101+
file.store_line("\"" + (lines[i] + "\n").json_escape() + "\"")
102+
103+
if !last_line.is_empty():
104+
file.store_line("\"" + last_line.json_escape() + "\"")
105+
106+
107+
func collect_lines_from_character(character: DialogicCharacter) -> void:
108+
super.collect_lines_from_character(character)
109+
locations.append(character.resource_path)
110+
111+
112+
func collect_lines_from_glossary(glossary: DialogicGlossary) -> void:
113+
super.collect_lines_from_glossary(glossary)
114+
locations.append(glossary.resource_path)
115+
116+
117+
func collect_lines_from_timeline(timeline: DialogicTimeline) -> void:
118+
super.collect_lines_from_timeline(timeline)
119+
locations.append(timeline.resource_path)
120+
121+
122+
class PotReference:
123+
var path: String
124+
var line_number: int
125+
126+
127+
func _init(path: String, line_number: int) -> void:
128+
self.path = path
129+
self.line_number = line_number
130+
131+
132+
func as_str() -> String:
133+
var str = ""
134+
if path.contains(" "):
135+
str += "\u2068" + path.trim_prefix("res://").replace("\n", "\\n") + "\u2069"
136+
else:
137+
str += path.trim_prefix("res://").replace("\n", "\\n")
138+
139+
if line_number >= 0:
140+
str += ":" + str(line_number)
141+
142+
return str
143+
144+
145+
class PotEntry:
146+
var key: String
147+
var translation: String
148+
var locations: Array[PotReference] = []

0 commit comments

Comments
 (0)