diff --git a/Data/data/i18n/core/en-GB.i18n b/Data/data/i18n/core/en-GB.i18n new file mode 100644 index 00000000..a04e3142 --- /dev/null +++ b/Data/data/i18n/core/en-GB.i18n @@ -0,0 +1,7 @@ +OK +Yes +No +Cancel +Retry +Close +Open diff --git a/Data/data/i18n/core/en-US.i18n b/Data/data/i18n/core/en-US.i18n new file mode 100644 index 00000000..a04e3142 --- /dev/null +++ b/Data/data/i18n/core/en-US.i18n @@ -0,0 +1,7 @@ +OK +Yes +No +Cancel +Retry +Close +Open diff --git a/Data/data/i18n/core/fr-FR.i18n b/Data/data/i18n/core/fr-FR.i18n new file mode 100644 index 00000000..2f035013 --- /dev/null +++ b/Data/data/i18n/core/fr-FR.i18n @@ -0,0 +1,7 @@ +OK +Oui +Non +Annuler +Réessayer +Fermer +Ouvrir diff --git a/Data/data/i18n/core/nl-BE.i18n b/Data/data/i18n/core/nl-BE.i18n new file mode 100644 index 00000000..1a5002f4 --- /dev/null +++ b/Data/data/i18n/core/nl-BE.i18n @@ -0,0 +1,7 @@ +OK +Ja +Nee +Annuleren +Opnieuw +Sluiten +Openen diff --git a/Data/data/i18n/core/nl-NL.i18n b/Data/data/i18n/core/nl-NL.i18n new file mode 100644 index 00000000..1a5002f4 --- /dev/null +++ b/Data/data/i18n/core/nl-NL.i18n @@ -0,0 +1,7 @@ +OK +Ja +Nee +Annuleren +Opnieuw +Sluiten +Openen diff --git a/Data/data/i18n/launcher/en-GB.i18n b/Data/data/i18n/launcher/en-GB.i18n new file mode 100644 index 00000000..5226a6e5 --- /dev/null +++ b/Data/data/i18n/launcher/en-GB.i18n @@ -0,0 +1,3 @@ +Apps +Files +Settings diff --git a/Data/data/i18n/launcher/en-US.i18n b/Data/data/i18n/launcher/en-US.i18n new file mode 100644 index 00000000..5226a6e5 --- /dev/null +++ b/Data/data/i18n/launcher/en-US.i18n @@ -0,0 +1,3 @@ +Apps +Files +Settings diff --git a/Data/data/i18n/launcher/fr-FR.i18n b/Data/data/i18n/launcher/fr-FR.i18n new file mode 100644 index 00000000..663a29ea --- /dev/null +++ b/Data/data/i18n/launcher/fr-FR.i18n @@ -0,0 +1,3 @@ +Appli +Fichiers +Réglages diff --git a/Data/data/i18n/launcher/nl-BE.i18n b/Data/data/i18n/launcher/nl-BE.i18n new file mode 100644 index 00000000..89a1e21f --- /dev/null +++ b/Data/data/i18n/launcher/nl-BE.i18n @@ -0,0 +1,3 @@ +Apps +Bestanden +Instellingen diff --git a/Data/data/i18n/launcher/nl-NL.i18n b/Data/data/i18n/launcher/nl-NL.i18n new file mode 100644 index 00000000..89a1e21f --- /dev/null +++ b/Data/data/i18n/launcher/nl-NL.i18n @@ -0,0 +1,3 @@ +Apps +Bestanden +Instellingen diff --git a/Tactility/Include/Tactility/i18n/Core.h b/Tactility/Include/Tactility/i18n/Core.h new file mode 100644 index 00000000..591664cb --- /dev/null +++ b/Tactility/Include/Tactility/i18n/Core.h @@ -0,0 +1,17 @@ +#pragma once + +// WARNING: This file is auto-generated. Do not edit manually. + +namespace tt::i18n::core { + +enum class Text { + OK = 0, + YES = 1, + NO = 2, + CANCEL = 3, + RETRY = 4, + CLOSE = 5, + OPEN = 6, +}; + +} diff --git a/Tactility/Include/Tactility/i18n/I18n.h b/Tactility/Include/Tactility/i18n/I18n.h new file mode 100644 index 00000000..9e091045 --- /dev/null +++ b/Tactility/Include/Tactility/i18n/I18n.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +namespace tt::i18n { + +class IndexedText { + +public: + + virtual ~IndexedText() = default; + + virtual const std::string& get(int index) const = 0; + + template + const std::string& get(EnumType value) const { return get(static_cast(value)); } + + const std::string& operator[](const int index) const { return get(index); } +}; + +std::shared_ptr loadIndexedText(const std::string& path); + +} \ No newline at end of file diff --git a/Tactility/Include/Tactility/i18n/Launcher.h b/Tactility/Include/Tactility/i18n/Launcher.h new file mode 100644 index 00000000..2a316726 --- /dev/null +++ b/Tactility/Include/Tactility/i18n/Launcher.h @@ -0,0 +1,13 @@ +#pragma once + +// WARNING: This file is auto-generated. Do not edit manually. + +namespace tt::i18n::launcher { + +enum class Text { + APPS = 0, + FILES = 1, + SETTINGS = 2, +}; + +} diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index af2e8ff5..82ec55af 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -2,15 +2,18 @@ #include "Tactility/app/AppRegistration.h" #include "Tactility/service/loader/Loader.h" +#include "Tactility/i18n/Launcher.h" + #include #include #include - -#define TAG "launcher" +#include namespace tt::app::launcher { +constexpr auto* TAG = "Launcher"; + static void onAppPressed(TT_UNUSED lv_event_t* e) { auto* appId = (const char*)lv_event_get_user_data(e); service::loader::startApp(appId); @@ -52,8 +55,11 @@ static lv_obj_t* createAppButton(lv_obj_t* parent, const char* title, const char } class LauncherApp : public App { + std::shared_ptr launcherText = i18n::loadIndexedText("/data/i18n/launcher"); void onCreate(TT_UNUSED AppContext& app) override { + assert(launcherText != nullptr); + BootProperties boot_properties; if (loadBootProperties(boot_properties) && !boot_properties.autoStartAppId.empty()) { TT_LOG_I(TAG, "Starting %s", boot_properties.autoStartAppId.c_str()); @@ -87,9 +93,14 @@ class LauncherApp : public App { auto apps_icon_path = paths->getSystemPathLvgl("icon_apps.png"); auto files_icon_path = paths->getSystemPathLvgl("icon_files.png"); auto settings_icon_path = paths->getSystemPathLvgl("icon_settings.png"); - createAppButton(wrapper, "Apps", apps_icon_path.c_str(), "AppList", 0); - createAppButton(wrapper, "Files", files_icon_path.c_str(), "Files", padding); - createAppButton(wrapper, "Settings", settings_icon_path.c_str(), "Settings", padding); + + const auto& apps_title = launcherText->get(i18n::launcher::Text::APPS); + const auto& files_title = launcherText->get(i18n::launcher::Text::FILES); + const auto& settings_title = launcherText->get(i18n::launcher::Text::SETTINGS); + + createAppButton(wrapper, apps_title.c_str(), apps_icon_path.c_str(), "AppList", 0); + createAppButton(wrapper, files_title.c_str(), files_icon_path.c_str(), "Files", padding); + createAppButton(wrapper, settings_title.c_str(), settings_icon_path.c_str(), "Settings", padding); } }; diff --git a/Tactility/Source/i18n/I18n.cpp b/Tactility/Source/i18n/I18n.cpp new file mode 100644 index 00000000..8ddc8fa3 --- /dev/null +++ b/Tactility/Source/i18n/I18n.cpp @@ -0,0 +1,85 @@ +#include "Tactility/i18n/I18n.h" +#include "Tactility/file/FileLock.h" + +#include +#include + +namespace tt::i18n { + +constexpr auto* TAG = "I18n"; +static std::string ERROR_RESULT = "TRANSLATION_ERROR"; + +class IndexedTextImplementation : IndexedText { + + std::vector data; + +public: + + explicit IndexedTextImplementation(std::vector data) : data(std::move(data)) {} + + const std::string& get(const int index) const override { + if (index < data.size()) { + return data[index]; + } else { + return ERROR_RESULT; + } + } +}; + +static std::string getDesiredLocale() { + // TODO: Implement locale settings + return "nl-NL"; +} + +static std::string getFallbackLocale() { + // TODO: Implement locale settings + return "en-GB"; +} + +static FILE* openI18nFile(const std::string& path) { + auto locale = getDesiredLocale(); + auto desired_file_path = std::format("{}/{}.i18n", path, locale); + auto* file = fopen(desired_file_path.c_str(), "r"); + if (file == nullptr) { + auto fallback_locale = getFallbackLocale(); + TT_LOG_W(TAG, "Translations not found for %s at %s", locale.c_str(), desired_file_path.c_str()); + auto fallback_file_path = std::format("{}/{}.i18n", path, getFallbackLocale()); + file = fopen(fallback_file_path.c_str(), "r"); + if (file == nullptr) { + TT_LOG_W(TAG, "Fallback translations not found for %s at %s", fallback_locale.c_str(), fallback_file_path.c_str()); + } + } + return file; +} + +std::shared_ptr loadIndexedText(const std::string& path) { + std::vector data; + + // We lock on folder level, because file is TBD + file::withLock(path, [&path, &data] { + auto* file = openI18nFile(path); + if (file != nullptr) { + char line[1024]; + // TODO: move to file::readLines(filePath, skipEndline, callback) + while (fgets(line, sizeof(line), file) != nullptr) { + // Strip newline + size_t line_length = strlen(line); + if (line_length > 0 && line[line_length - 1] == '\n') { + line[line_length - 1] = '\0'; + } + // Publish + data.push_back(line); + } + fclose(file); + } + }); + + if (data.empty()) { + return nullptr; + } + + auto result = std::make_shared(data); + return std::reinterpret_pointer_cast(result); +} + +} diff --git a/Translations/.gitignore b/Translations/.gitignore new file mode 100644 index 00000000..f54462c7 --- /dev/null +++ b/Translations/.gitignore @@ -0,0 +1,2 @@ +*.csv +*.ods# \ No newline at end of file diff --git a/Translations/README.md b/Translations/README.md new file mode 100644 index 00000000..535a2d52 --- /dev/null +++ b/Translations/README.md @@ -0,0 +1,25 @@ +# Translations + +## Requirements + +- Install [LibreOffice Calc](https://libreoffice.org/) +- Ensure you have Python 3 installed (Python 2 is not supported) + +## Steps + +To add new translations or edit existing ones, please follow these steps: + +1. Edit `Translations.ods` (see chapter below) +2. In LibreOffice Calc, select the relevant tab that you want to export +3. Click on `File` -> `Save a copy...` and save the file as `[tabname].csv` (without the "[]") +4. Repeat step 2 and 3 for all tabs that you updated +5. Run `python generate-all.py` + +## Notes + +- Do not commit the CSV files +- When editing the ODS file, make sure you don't paste in formatted data (use CTRL+Shift+V instead of CTRL+V) +- ODS export settings: + - Field delimiter: `,` + - String delimiter: `"` + - Encoding: `UTF-8` \ No newline at end of file diff --git a/Translations/Translations.ods b/Translations/Translations.ods new file mode 100644 index 00000000..4728ca6d Binary files /dev/null and b/Translations/Translations.ods differ diff --git a/Translations/generate-all.py b/Translations/generate-all.py new file mode 100644 index 00000000..7cf778cb --- /dev/null +++ b/Translations/generate-all.py @@ -0,0 +1,12 @@ +import os + +def generate(csvFile, headerFile, headerNamespace, dataPath): + if os.path.isfile(csvFile): + print(f"Generating {headerFile}") + os.system(f"python generate.py {csvFile} {headerFile} {headerNamespace} {dataPath}") + else: + print(f"Skipping {headerFile} (not found)") + +if __name__ == "__main__": + generate("system.csv", "../Tactility/Include/Tactility/i18n/Core.h", "tt::i18n::core", "../Data/data/i18n/core") + generate("launcher.csv", "../Tactility/Include/Tactility/i18n/Launcher.h", "tt::i18n::launcher", "../Data/data/i18n/launcher") diff --git a/Translations/generate.py b/Translations/generate.py new file mode 100644 index 00000000..166348a8 --- /dev/null +++ b/Translations/generate.py @@ -0,0 +1,90 @@ +import csv +import sys +from typing import List + +def load_csv(path: str, delimiter: str = ",", quotechar: str = '"', encoding: str = "utf-8", skip_header: bool = False) -> List[List[str]]: + """ + Load a CSV file into a list of rows, where each row is a list of strings. + + Args: + path: Path to the CSV file. + delimiter: Field delimiter character. + quotechar: Quote character. + encoding: File encoding. + skip_header: If True, skip the first row (header). + + Returns: + List of rows (list of lists of strings). + """ + rows: List[List[str]] = [] + with open(path, "r", encoding=encoding, newline="") as f: + reader = csv.reader(f, delimiter=delimiter, quotechar=quotechar) + if skip_header: + next(reader, None) + for row in reader: + rows.append(row) + return rows + +def print_help(): + print("Usage: python generate.py [csv_file] [header_file_path] [header_namespace] [i18n_directory]\n\n") + print("\t[csv_file] the CSV file containing the translations, exported from the .ods file") + print("\t[header_file_path] the path to the header file to be generated") + print("\t[header_namespace] the C++ namespace to use for the generated header file") + print("\t[i18n_directory] the directory where the .i18n files will be generated") + +def open_i18n_files(row, i18n_path): + result = [] + for i in range(1, len(row)): + filepath = f"{i18n_path}/{row[i]}.i18n" + print(f"Opening {filepath}") + file = open(filepath, "w") + result.append(file) + return result + +def close_i18n_files(files): + for file in files: + file.close() + +def generate_header(filepath, rows, namespace): + file = open(filepath, "w") + file.write("#pragma once\n\n") + file.write("// WARNING: This file is auto-generated. Do not edit manually.\n\n") + file.write(f"namespace {namespace}") + file.write(" {\n\n") + file.write("enum class Text {\n") + for i in range(1, len(rows)): + key = rows[i][0].upper() + file.write(f" {key} = {i - 1},\n") + file.write("};\n") + file.write("\n}\n") + file.close() + +def translate(rows, language_index, file): + for i in range(1, len(rows)): + value = rows[i][language_index] + if value == "": + value = f"{rows[i][0]}_untranslated" + file.write(value) + file.write("\n") + +if __name__ == "__main__": + if "--help" in sys.argv: + print_help() + sys.exit() + if len(sys.argv) != 5: + print_help() + sys.exit() + csv_file = sys.argv[1] + header_path = sys.argv[2] + header_namespace = sys.argv[3] + i18n_path = sys.argv[4] + rows = load_csv(csv_file) + if len(rows) == 0: + print("Error: CSV file is empty.") + sys.exit(1) + generate_header(header_path, rows, header_namespace) + i18n_files = open_i18n_files(rows[0], i18n_path) + for i in range(0, len(i18n_files)): + i18n_file = i18n_files[i] + translate(rows, i + 1, i18n_file) + close_i18n_files(i18n_files)