## Summary **What is the goal of this PR?** This PR introduces Internationalization (i18n) support, enabling users to switch the UI language dynamically. **What changes are included?** - Core Logic: Added I18n class (`lib/I18n/I18n.h/cpp`) to manage language state and string retrieval. - Data Structures: - `lib/I18n/I18nStrings.h/cpp`: Static string arrays for each supported language. - `lib/I18n/I18nKeys.h`: Enum definitions for type-safe string access. - `lib/I18n/translations.csv`: single source of truth. - Documentation: Added `docs/i18n.md` detailing the workflow for developers and translators. - New Settings activity: `src/activities/settings/LanguageSelectActivity.h/cpp` ## Additional Context This implementation (building on concepts from #505) prioritizes performance and memory efficiency. The core approach is to store all localized strings for each language in dedicated arrays and access them via enums. This provides O(1) access with zero runtime overhead, and avoids the heap allocations, hashing, and collision handling required by `std::map` or `std::unordered_map`. The main trade-off is that enums and string arrays must remain perfectly synchronized—any mismatch would result in incorrect strings being displayed in the UI. To eliminate this risk, I added a Python script that automatically generates `I18nStrings.h/.cpp` and `I18nKeys.h` from a CSV file, which will serve as the single source of truth for all translations. The full design and workflow are documented in `docs/i18n.md`. ### Next Steps - [x] Python script `generate_i18n.py` to auto-generate C++ files from CSV - [x] Populate translations.csv with initial translations. Currently available translations: English, Español, Français, Deutsch, Čeština, Português (Brasil), Русский, Svenska. Thanks, community! **Status:** EDIT: ready to be merged. As a proof of concept, the SPANISH strings currently mirror the English ones, but are fully uppercased. --- ### AI Usage Did you use AI tools to help write this code? _**< PARTIALLY >**_ I used AI for the black work of replacing strings with I18n references across the project, and for generating the documentation. EDIT: also some help with merging changes from master. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: yeyeto2788 <juanernestobiondi@gmail.com>
410 lines
12 KiB
C++
410 lines
12 KiB
C++
#include "KOReaderSyncActivity.h"
|
|
|
|
#include <GfxRenderer.h>
|
|
#include <I18n.h>
|
|
#include <Logging.h>
|
|
#include <WiFi.h>
|
|
#include <esp_sntp.h>
|
|
|
|
#include "KOReaderCredentialStore.h"
|
|
#include "KOReaderDocumentId.h"
|
|
#include "MappedInputManager.h"
|
|
#include "activities/network/WifiSelectionActivity.h"
|
|
#include "components/UITheme.h"
|
|
#include "fontIds.h"
|
|
|
|
namespace {
|
|
void syncTimeWithNTP() {
|
|
// Stop SNTP if already running (can't reconfigure while running)
|
|
if (esp_sntp_enabled()) {
|
|
esp_sntp_stop();
|
|
}
|
|
|
|
// Configure SNTP
|
|
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
|
|
esp_sntp_setservername(0, "pool.ntp.org");
|
|
esp_sntp_init();
|
|
|
|
// Wait for time to sync (with timeout)
|
|
int retry = 0;
|
|
const int maxRetries = 50; // 5 seconds max
|
|
while (sntp_get_sync_status() != SNTP_SYNC_STATUS_COMPLETED && retry < maxRetries) {
|
|
vTaskDelay(100 / portTICK_PERIOD_MS);
|
|
retry++;
|
|
}
|
|
|
|
if (retry < maxRetries) {
|
|
LOG_DBG("KOSync", "NTP time synced");
|
|
} else {
|
|
LOG_DBG("KOSync", "NTP sync timeout, using fallback");
|
|
}
|
|
}
|
|
} // namespace
|
|
|
|
void KOReaderSyncActivity::onWifiSelectionComplete(const bool success) {
|
|
exitActivity();
|
|
|
|
if (!success) {
|
|
LOG_DBG("KOSync", "WiFi connection failed, exiting");
|
|
onCancel();
|
|
return;
|
|
}
|
|
|
|
LOG_DBG("KOSync", "WiFi connected, starting sync");
|
|
|
|
{
|
|
RenderLock lock(*this);
|
|
state = SYNCING;
|
|
statusMessage = tr(STR_SYNCING_TIME);
|
|
}
|
|
requestUpdate();
|
|
|
|
// Sync time with NTP before making API requests
|
|
syncTimeWithNTP();
|
|
|
|
{
|
|
RenderLock lock(*this);
|
|
statusMessage = tr(STR_CALC_HASH);
|
|
}
|
|
requestUpdate();
|
|
|
|
performSync();
|
|
}
|
|
|
|
void KOReaderSyncActivity::performSync() {
|
|
// Calculate document hash based on user's preferred method
|
|
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
|
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
|
} else {
|
|
documentHash = KOReaderDocumentId::calculate(epubPath);
|
|
}
|
|
if (documentHash.empty()) {
|
|
{
|
|
RenderLock lock(*this);
|
|
state = SYNC_FAILED;
|
|
statusMessage = tr(STR_HASH_FAILED);
|
|
}
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
LOG_DBG("KOSync", "Document hash: %s", documentHash.c_str());
|
|
|
|
{
|
|
RenderLock lock(*this);
|
|
statusMessage = tr(STR_FETCH_PROGRESS);
|
|
}
|
|
requestUpdateAndWait();
|
|
|
|
// Fetch remote progress
|
|
const auto result = KOReaderSyncClient::getProgress(documentHash, remoteProgress);
|
|
|
|
if (result == KOReaderSyncClient::NOT_FOUND) {
|
|
// No remote progress - offer to upload
|
|
{
|
|
RenderLock lock(*this);
|
|
state = NO_REMOTE_PROGRESS;
|
|
hasRemoteProgress = false;
|
|
}
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
if (result != KOReaderSyncClient::OK) {
|
|
{
|
|
RenderLock lock(*this);
|
|
state = SYNC_FAILED;
|
|
statusMessage = KOReaderSyncClient::errorString(result);
|
|
}
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
// Convert remote progress to CrossPoint position
|
|
hasRemoteProgress = true;
|
|
KOReaderPosition koPos = {remoteProgress.progress, remoteProgress.percentage};
|
|
remotePosition = ProgressMapper::toCrossPoint(epub, koPos, totalPagesInSpine);
|
|
|
|
// Calculate local progress in KOReader format (for display)
|
|
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
|
|
localProgress = ProgressMapper::toKOReader(epub, localPos);
|
|
|
|
{
|
|
RenderLock lock(*this);
|
|
state = SHOWING_RESULT;
|
|
selectedOption = 0; // Default to "Apply"
|
|
}
|
|
requestUpdate();
|
|
}
|
|
|
|
void KOReaderSyncActivity::performUpload() {
|
|
{
|
|
RenderLock lock(*this);
|
|
state = UPLOADING;
|
|
statusMessage = tr(STR_UPLOAD_PROGRESS);
|
|
}
|
|
requestUpdate();
|
|
requestUpdateAndWait();
|
|
|
|
// Convert current position to KOReader format
|
|
CrossPointPosition localPos = {currentSpineIndex, currentPage, totalPagesInSpine};
|
|
KOReaderPosition koPos = ProgressMapper::toKOReader(epub, localPos);
|
|
|
|
KOReaderProgress progress;
|
|
progress.document = documentHash;
|
|
progress.progress = koPos.xpath;
|
|
progress.percentage = koPos.percentage;
|
|
|
|
const auto result = KOReaderSyncClient::updateProgress(progress);
|
|
|
|
if (result != KOReaderSyncClient::OK) {
|
|
{
|
|
RenderLock lock(*this);
|
|
state = SYNC_FAILED;
|
|
statusMessage = KOReaderSyncClient::errorString(result);
|
|
}
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
{
|
|
RenderLock lock(*this);
|
|
state = UPLOAD_COMPLETE;
|
|
}
|
|
requestUpdate();
|
|
}
|
|
|
|
void KOReaderSyncActivity::onEnter() {
|
|
ActivityWithSubactivity::onEnter();
|
|
|
|
// Check for credentials first
|
|
if (!KOREADER_STORE.hasCredentials()) {
|
|
state = NO_CREDENTIALS;
|
|
requestUpdate();
|
|
return;
|
|
}
|
|
|
|
// Turn on WiFi
|
|
LOG_DBG("KOSync", "Turning on WiFi...");
|
|
WiFi.mode(WIFI_STA);
|
|
|
|
// Check if already connected
|
|
if (WiFi.status() == WL_CONNECTED) {
|
|
LOG_DBG("KOSync", "Already connected to WiFi");
|
|
state = SYNCING;
|
|
statusMessage = tr(STR_SYNCING_TIME);
|
|
requestUpdate();
|
|
|
|
// Perform sync directly (will be handled in loop)
|
|
xTaskCreate(
|
|
[](void* param) {
|
|
auto* self = static_cast<KOReaderSyncActivity*>(param);
|
|
// Sync time first
|
|
syncTimeWithNTP();
|
|
{
|
|
RenderLock lock(*self);
|
|
self->statusMessage = tr(STR_CALC_HASH);
|
|
}
|
|
self->requestUpdate();
|
|
self->performSync();
|
|
vTaskDelete(nullptr);
|
|
},
|
|
"SyncTask", 4096, this, 1, nullptr);
|
|
return;
|
|
}
|
|
|
|
// Launch WiFi selection subactivity
|
|
LOG_DBG("KOSync", "Launching WifiSelectionActivity...");
|
|
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
|
|
[this](const bool connected) { onWifiSelectionComplete(connected); }));
|
|
}
|
|
|
|
void KOReaderSyncActivity::onExit() {
|
|
ActivityWithSubactivity::onExit();
|
|
|
|
// Turn off wifi
|
|
WiFi.disconnect(false);
|
|
delay(100);
|
|
WiFi.mode(WIFI_OFF);
|
|
delay(100);
|
|
}
|
|
|
|
void KOReaderSyncActivity::render(Activity::RenderLock&&) {
|
|
if (subActivity) {
|
|
return;
|
|
}
|
|
|
|
const auto pageWidth = renderer.getScreenWidth();
|
|
|
|
renderer.clearScreen();
|
|
renderer.drawCenteredText(UI_12_FONT_ID, 15, tr(STR_KOREADER_SYNC), true, EpdFontFamily::BOLD);
|
|
|
|
if (state == NO_CREDENTIALS) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_NO_CREDENTIALS_MSG), true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, tr(STR_KOREADER_SETUP_HINT));
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (state == SYNCING || state == UPLOADING) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 300, statusMessage.c_str(), true, EpdFontFamily::BOLD);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (state == SHOWING_RESULT) {
|
|
// Show comparison
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 120, tr(STR_PROGRESS_FOUND), true, EpdFontFamily::BOLD);
|
|
|
|
// Get chapter names from TOC
|
|
const int remoteTocIndex = epub->getTocIndexForSpineIndex(remotePosition.spineIndex);
|
|
const int localTocIndex = epub->getTocIndexForSpineIndex(currentSpineIndex);
|
|
const std::string remoteChapter =
|
|
(remoteTocIndex >= 0) ? epub->getTocItem(remoteTocIndex).title
|
|
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(remotePosition.spineIndex + 1));
|
|
const std::string localChapter =
|
|
(localTocIndex >= 0) ? epub->getTocItem(localTocIndex).title
|
|
: (std::string(tr(STR_SECTION_PREFIX)) + std::to_string(currentSpineIndex + 1));
|
|
|
|
// Remote progress - chapter and page
|
|
renderer.drawText(UI_10_FONT_ID, 20, 160, tr(STR_REMOTE_LABEL), true);
|
|
char remoteChapterStr[128];
|
|
snprintf(remoteChapterStr, sizeof(remoteChapterStr), " %s", remoteChapter.c_str());
|
|
renderer.drawText(UI_10_FONT_ID, 20, 185, remoteChapterStr);
|
|
char remotePageStr[64];
|
|
snprintf(remotePageStr, sizeof(remotePageStr), tr(STR_PAGE_OVERALL_FORMAT), remotePosition.pageNumber + 1,
|
|
remoteProgress.percentage * 100);
|
|
renderer.drawText(UI_10_FONT_ID, 20, 210, remotePageStr);
|
|
|
|
if (!remoteProgress.device.empty()) {
|
|
char deviceStr[64];
|
|
snprintf(deviceStr, sizeof(deviceStr), tr(STR_DEVICE_FROM_FORMAT), remoteProgress.device.c_str());
|
|
renderer.drawText(UI_10_FONT_ID, 20, 235, deviceStr);
|
|
}
|
|
|
|
// Local progress - chapter and page
|
|
renderer.drawText(UI_10_FONT_ID, 20, 270, tr(STR_LOCAL_LABEL), true);
|
|
char localChapterStr[128];
|
|
snprintf(localChapterStr, sizeof(localChapterStr), " %s", localChapter.c_str());
|
|
renderer.drawText(UI_10_FONT_ID, 20, 295, localChapterStr);
|
|
char localPageStr[64];
|
|
snprintf(localPageStr, sizeof(localPageStr), tr(STR_PAGE_TOTAL_OVERALL_FORMAT), currentPage + 1, totalPagesInSpine,
|
|
localProgress.percentage * 100);
|
|
renderer.drawText(UI_10_FONT_ID, 20, 320, localPageStr);
|
|
|
|
const int optionY = 350;
|
|
const int optionHeight = 30;
|
|
|
|
// Apply option
|
|
if (selectedOption == 0) {
|
|
renderer.fillRect(0, optionY - 2, pageWidth - 1, optionHeight);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, 20, optionY, tr(STR_APPLY_REMOTE), selectedOption != 0);
|
|
|
|
// Upload option
|
|
if (selectedOption == 1) {
|
|
renderer.fillRect(0, optionY + optionHeight - 2, pageWidth - 1, optionHeight);
|
|
}
|
|
renderer.drawText(UI_10_FONT_ID, 20, optionY + optionHeight, tr(STR_UPLOAD_LOCAL), selectedOption != 1);
|
|
|
|
// Bottom button hints: show Back and Select
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_SELECT), "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (state == NO_REMOTE_PROGRESS) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_NO_REMOTE_MSG), true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, tr(STR_UPLOAD_PROMPT));
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), tr(STR_UPLOAD), "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (state == UPLOAD_COMPLETE) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 300, tr(STR_UPLOAD_SUCCESS), true, EpdFontFamily::BOLD);
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
|
|
if (state == SYNC_FAILED) {
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 280, tr(STR_SYNC_FAILED_MSG), true, EpdFontFamily::BOLD);
|
|
renderer.drawCenteredText(UI_10_FONT_ID, 320, statusMessage.c_str());
|
|
|
|
const auto labels = mappedInput.mapLabels(tr(STR_BACK), "", "", "");
|
|
GUI.drawButtonHints(renderer, labels.btn1, labels.btn2, labels.btn3, labels.btn4);
|
|
renderer.displayBuffer();
|
|
return;
|
|
}
|
|
}
|
|
|
|
void KOReaderSyncActivity::loop() {
|
|
if (subActivity) {
|
|
subActivity->loop();
|
|
return;
|
|
}
|
|
|
|
if (state == NO_CREDENTIALS || state == SYNC_FAILED || state == UPLOAD_COMPLETE) {
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
|
onCancel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state == SHOWING_RESULT) {
|
|
// Navigate options
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Up) ||
|
|
mappedInput.wasPressed(MappedInputManager::Button::Left)) {
|
|
selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
|
|
requestUpdate();
|
|
} else if (mappedInput.wasPressed(MappedInputManager::Button::Down) ||
|
|
mappedInput.wasPressed(MappedInputManager::Button::Right)) {
|
|
selectedOption = (selectedOption + 1) % 2; // Wrap around among 2 options
|
|
requestUpdate();
|
|
}
|
|
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
|
if (selectedOption == 0) {
|
|
// Apply remote progress
|
|
onSyncComplete(remotePosition.spineIndex, remotePosition.pageNumber);
|
|
} else if (selectedOption == 1) {
|
|
// Upload local progress
|
|
performUpload();
|
|
}
|
|
}
|
|
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
|
onCancel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (state == NO_REMOTE_PROGRESS) {
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Confirm)) {
|
|
// Calculate hash if not done yet
|
|
if (documentHash.empty()) {
|
|
if (KOREADER_STORE.getMatchMethod() == DocumentMatchMethod::FILENAME) {
|
|
documentHash = KOReaderDocumentId::calculateFromFilename(epubPath);
|
|
} else {
|
|
documentHash = KOReaderDocumentId::calculate(epubPath);
|
|
}
|
|
}
|
|
performUpload();
|
|
}
|
|
|
|
if (mappedInput.wasPressed(MappedInputManager::Button::Back)) {
|
|
onCancel();
|
|
}
|
|
return;
|
|
}
|
|
}
|