Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement gettext translation file generation #2356

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions addons/dialogic/Editor/Settings/TranslationFiles/csv.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
class_name DialogicTranslationCsvFile
extends DialogicTranslationFile
## Generates translation files in CSV format.

var lines: Array[PackedStringArray] = []
## Dictionary of lines from the original file.
## Key: String, Value: PackedStringArray
var old_lines: Dictionary = {}

## The amount of columns the CSV file has after loading it.
## Used to add trailing commas to new lines.
var column_count := 0

## The underlying file used to read and write the CSV file.
var file: FileAccess

## Whether this CSV handler should add newlines as a separator between sections.
## A section may be a new character, new timeline, or new glossary item inside
## a per-project file.
var add_separator: bool = false


## Attempts to load the CSV file from [param file_path].
## If the file does not exist, a single entry is added to the [member lines]
## array.
## The [param separator_enabled] enables adding newlines as a separator to
## per-project files. This is useful for readability.
func _init(file_path: String, original_locale: String, separator_enabled: bool) -> void:
super._init(file_path, original_locale)

add_separator = separator_enabled

# The first entry must be the locale row.
# [method collect_lines_from_timeline] will add the other locales, if any.
var locale_array_line := PackedStringArray(["keys", original_locale])
lines.append(locale_array_line)

if is_new_file:
# The "keys" and original locale are the only columns in a new file.
# For example: "keys, en"
column_count = 2
return

file = FileAccess.open(file_path, FileAccess.READ)

var locale_csv_row := file.get_csv_line()
column_count = locale_csv_row.size()
var locale_key := locale_csv_row[0]

old_lines[locale_key] = locale_csv_row

_read_file_into_lines()


## Private function to read the CSV file into the [member lines] array.
## Cannot be called on a new file.
func _read_file_into_lines() -> void:
while not file.eof_reached():
var line := file.get_csv_line()
var row_key := line[0]

old_lines[row_key] = line


func _append(key: String, value: String, _path: String, _line_number: int = -1) -> void:
var array_line := PackedStringArray([key, value])
lines.append(array_line)


## Appends an empty line to the [member lines] array.
func _append_separator() -> void:
if add_separator:
var empty_line := PackedStringArray(["", ""])
lines.append(empty_line)


## Clears the CSV file on disk and writes the current [member lines] array to it.
## Uses the [member old_lines] dictionary to update existing translations.
## If a translation row misses a column, a trailing comma will be added to
## conform to the CSV file format.
##
## If the locale CSV line was collected only, a new file won't be created and
## already existing translations won't be updated.
func update_file_on_disk() -> void:
# None or locale row only.
if lines.size() < 2:
print_rich("[color=yellow]No lines for the CSV file, skipping: " + used_file_path)

return

# Clear the current CSV file.
file = FileAccess.open(used_file_path, FileAccess.WRITE)

for line in lines:
var row_key := line[0]

# In case there might be translations for this line already,
# add them at the end again (orig locale text is replaced).
if row_key in old_lines:
var old_line: PackedStringArray = old_lines[row_key]
var updated_line: PackedStringArray = line + old_line.slice(2)

var line_columns: int = updated_line.size()
var line_columns_to_add := column_count - line_columns

# Add trailing commas to match the amount of columns.
for _i in range(line_columns_to_add):
updated_line.append("")

file.store_csv_line(updated_line)
updated_rows += 1

else:
var line_columns: int = line.size()
var line_columns_to_add := column_count - line_columns

# Add trailing commas to match the amount of columns.
for _i in range(line_columns_to_add):
line.append("")

file.store_csv_line(line)
new_rows += 1

file.close()
148 changes: 148 additions & 0 deletions addons/dialogic/Editor/Settings/TranslationFiles/gettext.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
class_name DialogicTranslationGettextFile
extends DialogicTranslationFile
## Generates translation files in gettext format.

var translations: Array[PotEntry] = []

## Configured original locale.
var original_locale: String

## Locations of the source files included in this translation.
var locations: Array[String] = []


## There is no need to load the old file(s) here, because every locale has its own file
## and this class doens't touch them.
func _init(file_path: String, original_locale: String) -> void:
super._init(file_path, original_locale)
self.original_locale = original_locale


func _append(key: String, value: String, path: String, line_number: int = -1) -> void:
var entry = PotEntry.new()
entry.key = key
entry.translation = value
entry.locations.append(PotReference.new(path, line_number))
translations.append(entry)


## gettext doesn't support separators so this is a no-op.
func _append_separator() -> void:
pass


## Overwrites the .pot file and the .po file of the original locale with the current [member translations] array.
func update_file_on_disk() -> void:
# Overwrite the POT file.
var file = FileAccess.open(used_file_path, FileAccess.WRITE)
_write_header(file)
for entry in translations:
_write_entry(file, entry, "")
file.close()

# Overwrite the original_locale PO file.
file = FileAccess.open(used_file_path.trim_suffix(".pot") + "." + original_locale + ".po", FileAccess.WRITE)
_write_header(file, original_locale)
for entry in translations:
_write_entry(file, entry)
file.close()


# This is based on POTGenerator::_write_to_pot() which unfortunately isn't exposed to gdscript.
func _write_header(file: FileAccess, locale: String = "") -> void:
var project_name = ProjectSettings.get("application/config/name");
var language_header = locale if !locale.is_empty() else "LANGUAGE"
file.store_line("# " + language_header + " translation for " + project_name + " for the following files:")

locations.sort()
for location in locations:
file.store_line("# " + location)

file.store_line("")
file.store_line("#, fuzzy");
file.store_line("msgid \"\"")
file.store_line("msgstr \"\"")
file.store_line("\"Project-Id-Version: " + project_name + "\\n\"")
if !locale.is_empty():
file.store_line("\"Language: " + locale + "\\n\"")
file.store_line("\"MIME-Version: 1.0\\n\"")
file.store_line("\"Content-Type: text/plain; charset=UTF-8\\n\"")
file.store_line("\"Content-Transfer-Encoding: 8-bit\\n\"")


func _write_entry(file: FileAccess, entry: PotEntry, value: String = entry.translation) -> void:
file.store_line("")

entry.locations.sort_custom(func (a: String, b: String): return b > a)
for location in entry.locations:
file.store_line("#: " + location.as_str())

_write_line(file, "msgid", entry.key)
_write_line(file, "msgstr", value)


# This is based on POTGenerator::_write_msgid() which unfortunately isn't exposed to gdscript.
func _write_line(file: FileAccess, type: String, value: String) -> void:
file.store_string(type + " ")
if value.is_empty():
file.store_line("\"\"")
return

var lines = value.split("\n")
var last_line = lines[lines.size() - 1]
var pot_line_count = lines.size()
if last_line.is_empty():
pot_line_count -= 1

if pot_line_count > 1:
file.store_line("\"\"")

for i in range(0, lines.size() - 1):
file.store_line("\"" + (lines[i] + "\n").json_escape() + "\"")

if !last_line.is_empty():
file.store_line("\"" + last_line.json_escape() + "\"")


func collect_lines_from_character(character: DialogicCharacter) -> void:
super.collect_lines_from_character(character)
locations.append(character.resource_path)


func collect_lines_from_glossary(glossary: DialogicGlossary) -> void:
super.collect_lines_from_glossary(glossary)
locations.append(glossary.resource_path)


func collect_lines_from_timeline(timeline: DialogicTimeline) -> void:
super.collect_lines_from_timeline(timeline)
locations.append(timeline.resource_path)


class PotReference:
var path: String
var line_number: int


func _init(path: String, line_number: int) -> void:
self.path = path
self.line_number = line_number


func as_str() -> String:
var str = ""
if path.contains(" "):
str += "\u2068" + path.trim_prefix("res://").replace("\n", "\\n") + "\u2069"
else:
str += path.trim_prefix("res://").replace("\n", "\\n")

if line_number >= 0:
str += ":" + str(line_number)

return str


class PotEntry:
var key: String
var translation: String
var locations: Array[PotReference] = []
Loading
Loading