feat(tools): improve tool for sync and update I18N lines (PR #2494)

* feat(tools): add to NLSAddNewLines tool remove lines if it not found on default reference & update commented untranslated line from reference

* fix: sync i18n translation from english reference file

* rename class and task, adjust code style

---------

Co-authored-by: Skylot <118523+skylot@users.noreply.github.com>
This commit is contained in:
Yaroslav
2025-05-19 21:17:07 +03:00
committed by GitHub
parent 580f25faae
commit bee476895c
8 changed files with 210 additions and 150 deletions
+3 -3
View File
@@ -228,10 +228,10 @@ val copyDistWinWithJre by tasks.registering(Copy::class) {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
val addNewNLSLines by tasks.registering(JavaExec::class) {
val syncNLSLines by tasks.registering(JavaExec::class) {
group = "jadx-dev"
description = "Utility task to add new/missing translation lines"
description = "Utility task to sync new/missing translation using EN as a reference"
classpath = sourceSets.main.get().runtimeClasspath
mainClass.set("jadx.gui.utils.tools.NLSAddNewLines")
mainClass.set("jadx.gui.utils.tools.SyncNLSLines")
}
@@ -1,124 +0,0 @@
package jadx.gui.utils.tools;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Automatically add new i18n lines from reference (EN) into other languages
*/
public class NLSAddNewLines {
private static final Logger LOG = LoggerFactory.getLogger(NLSAddNewLines.class);
private static final Path I18N_PATH = Paths.get("src/main/resources/i18n/");
private static final String GUI_MODULE_DIR = "jadx-gui";
public static void main(String[] args) {
try {
process();
} catch (Exception e) {
LOG.error("Failed to add new i18n lines", e);
}
}
private static void process() throws Exception {
String reference = "Messages_en_US.properties";
Path refPath = getRefPath(reference);
List<String> refLines = Files.readAllLines(refPath);
try (Stream<Path> pathStream = Files.list(refPath.toAbsolutePath().getParent())) {
pathStream.forEach(path -> {
if (path.getFileName().equals(refPath.getFileName())) {
return;
}
try {
applyFix(refLines, path);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
private static void applyFix(List<String> refLines, Path path) throws IOException {
List<String> lines = Files.readAllLines(path);
int linesCount = lines.size();
boolean updated = false;
for (int i = 0; i < linesCount; i++) {
String line = lines.get(i);
String refLine = refLines.get(i);
if (isSameKey(refLine, line)) {
if (line.startsWith("#") && line.endsWith("=")) {
// add ref text if missing to simplify translation
lines.set(i, '#' + refLine);
updated = true;
}
} else {
if (refLine.isEmpty()) {
lines.add(i, "");
} else {
lines.add(i, '#' + refLine);
}
updated = true;
}
}
if (updated) {
LOG.info("Updating {}", path);
Files.write(path, lines, StandardCharsets.UTF_8);
}
}
private static boolean isSameKey(String refLine, String line) {
int refLen = refLine.length();
int len = line.length();
if (refLen == 0) {
return len == 0;
}
if (len == 0) {
return false;
}
int pos = 0;
// skip comment and spaces
while (pos < len) {
char ch = line.charAt(pos);
if (ch == '#' || ch == ' ') {
pos++;
} else {
break;
}
}
int refPos = 0;
while (true) {
char refCh = refLine.charAt(refPos);
if (refCh == ' ' || refCh == '=') {
return true;
}
char ch = line.charAt(pos);
if (refCh != ch) {
return false;
}
refPos++;
pos++;
}
}
private static Path getRefPath(String reference) {
Path path = I18N_PATH.resolve(reference);
if (Files.exists(path)) {
return path;
}
Path rootPath = Paths.get(GUI_MODULE_DIR).resolve(I18N_PATH).resolve(reference);
if (Files.exists(rootPath)) {
return rootPath;
}
throw new RuntimeException("Can't find reference I18N: " + reference);
}
}
@@ -0,0 +1,184 @@
package jadx.gui.utils.tools;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Automatically synchronizes i18n files with a reference (EN) file.
* - Adds new lines from the reference file (commented out) to other language files.
* - Removes lines from other language files that are not present in the reference file.
* - Updates commented-out empty translations in other files with the reference text (commented).
*/
public class SyncNLSLines {
private static final Logger LOG = LoggerFactory.getLogger(SyncNLSLines.class);
private static final Path I18N_PATH = Paths.get("src/main/resources/i18n/");
private static final String REFERENCE_FILE_NAME = "Messages_en_US.properties";
/**
* Assumes this tool runs from the project root and jadx-gui is a direct subdirectory.
* If jadx-gui is the project root, this might need adjustment or to be removed
* if I18N_PATH is already relative to jadx-gui.
*/
private static final String GUI_MODULE_DIR_NAME = "jadx-gui";
private static final Path GUI_MODULE_PREFIX_PATH = Paths.get(GUI_MODULE_DIR_NAME);
public static void main(String[] args) {
try {
process();
} catch (Exception e) {
LOG.error("Failed to process i18n files", e);
}
}
private static void process() throws Exception {
Path refPath = getRefPath(REFERENCE_FILE_NAME);
if (!Files.exists(refPath)) {
LOG.error("Reference i18n file not found: {}", REFERENCE_FILE_NAME);
return;
}
LOG.info("Using reference file: {}", refPath.toAbsolutePath());
Path i18nDir = refPath.toAbsolutePath().getParent();
if (i18nDir == null) {
LOG.error("Could not determine i18n directory from reference path: {}", refPath);
return;
}
List<String> refFileLines = Files.readAllLines(refPath, StandardCharsets.UTF_8);
try (Stream<Path> pathStream = Files.list(i18nDir)) {
pathStream.filter(path -> {
String fileName = path.getFileName().toString();
return !fileName.equals(REFERENCE_FILE_NAME)
&& fileName.startsWith("Messages_")
&& fileName.endsWith(".properties");
})
.forEach(targetPath -> {
try {
LOG.info("Processing target file: {}", targetPath.toAbsolutePath());
applySync(refFileLines, targetPath);
} catch (Exception e) {
LOG.error("Failed to sync file: {}", targetPath, e);
}
});
}
LOG.info("I18N synchronization process finished.");
}
/**
* Parses a list of lines from a properties file into a map of key-value pairs.
* Preserves the full line as value. Comments and empty lines will have null keys.
*/
private static Map<String, String> parseProperties(List<String> lines) {
Map<String, String> properties = new LinkedHashMap<>();
for (String line : lines) {
String key = extractKey(line);
// If the key is null, it's a comment or blank line, we might not need these in the map.
// If we iterate over the original refFileLines later.
// For simplicity here, we only store actual properties.
if (key != null) {
properties.put(key, line);
}
}
return properties;
}
/**
* Extracts the key from a properties file line.
* Returns null if the line is a comment, empty, or doesn't seem to be a key-value pair.
*/
private static String extractKey(String line) {
String trimmedLine = line.trim();
if (trimmedLine.isEmpty() || trimmedLine.startsWith("#")) {
// Comment or empty line
return null;
}
int separatorIndex = trimmedLine.indexOf('=');
if (separatorIndex == -1) {
// Line without a separator could be a key with no value (less common in i18n)
// or just a malformed line. For now, let's consider it a key if it's not empty.
return trimmedLine;
}
return trimmedLine.substring(0, separatorIndex).trim();
}
private static void applySync(List<String> refFileLines, Path targetPath) throws IOException {
List<String> originalTargetLines = Files.readAllLines(targetPath, StandardCharsets.UTF_8);
Map<String, String> targetProperties = parseProperties(originalTargetLines);
List<String> newTargetLines = new ArrayList<>(refFileLines.size());
boolean updated = false;
for (String refLine : refFileLines) {
String refKey = extractKey(refLine);
if (refKey == null) {
// It's a comment or blank line from reference
newTargetLines.add(refLine);
} else {
// It's a property line from reference
if (targetProperties.containsKey(refKey)) {
String targetLine = targetProperties.get(refKey);
// Original logic: if target line is like "#key=" (commented, no value)
// then use the commented reference value.
String trimmed = targetLine.trim();
if (trimmed.startsWith("#")
&& trimmed.substring(1).trim().startsWith(refKey) // ensure it's the same key
&& trimmed.endsWith("=")) {
// Use reference line, commented
newTargetLines.add('#' + refLine.trim());
} else {
// Use existing target line
newTargetLines.add(targetLine);
}
} else {
// Key from reference is missing in target, add it commented out
newTargetLines.add('#' + refLine.trim());
}
}
}
// Check if files are different
if (originalTargetLines.size() != newTargetLines.size()) {
updated = true;
} else {
for (int i = 0; i < originalTargetLines.size(); i++) {
if (!originalTargetLines.get(i).equals(newTargetLines.get(i))) {
updated = true;
break;
}
}
}
if (updated) {
LOG.info("Updating {} ({} lines -> {} lines)", targetPath.getFileName(), originalTargetLines.size(), newTargetLines.size());
Files.write(targetPath, newTargetLines, StandardCharsets.UTF_8);
} else {
LOG.info("No changes needed for {}", targetPath.getFileName());
}
}
private static Path getRefPath(String referenceFileName) {
// Path relative to project root (where src/main/resources exists)
Path projectRootRelative = I18N_PATH.resolve(referenceFileName);
if (Files.exists(projectRootRelative)) {
return projectRootRelative.toAbsolutePath();
}
// Path relative to a module (e.g. jadx-gui/src/main/resources)
// This assumes the script is run from one level above GUI_MODULE_DIR_NAME
Path moduleRelative = GUI_MODULE_PREFIX_PATH.resolve(I18N_PATH).resolve(referenceFileName);
if (Files.exists(moduleRelative)) {
return moduleRelative.toAbsolutePath();
}
// Path if tool runs from within the GUI_MODULE_DIR_NAME itself
Path currentDirRelative = Paths.get(".").resolve(I18N_PATH).resolve(referenceFileName);
if (Files.exists(currentDirRelative)) {
return currentDirRelative.toAbsolutePath();
}
throw new RuntimeException("Can't find reference I18N: " + referenceFileName);
}
}
@@ -8,10 +8,10 @@ menu.preferences=Preferencias
menu.sync=Sincronizar con el editor
menu.flatten=Mostrar paquetes en vista plana
#menu.enable_preview_tab=Enable Preview Tab
#menu.heapUsageBar=Show memory usage bar
#menu.alwaysSelectOpened=Always Select Opened File/Class
#menu.dock_log=Dock log viewer
#menu.dock_quick_tabs=Dock quick tabs
#menu.heapUsageBar=Show Memory Usage Bar
#menu.alwaysSelectOpened=Auto Select in Tree
#menu.dock_log=Dock Log Viewer
#menu.dock_quick_tabs=Show Quick Tabs
menu.navigation=Navegación
menu.text_search=Buscar texto
menu.class_search=Buscar clase
@@ -33,7 +33,7 @@ menu.update_label=¡Nueva versión %s disponible!
#menu.hex_viewer=Hex Viewer
file.open_action=Abrir archivo...
#file.add_files_action=Add files ...
#file.add_files_action=Add files
file.open_title=Abrir archivo
#file.open_project=Open project
#file.new_project=New project
@@ -192,11 +192,11 @@ usage_dialog.label=Usage for:
#rename_dialog.class_help=Enter full name to move class to another package. Start with '.' to move to default (empty) package
#export_dialog.title=Export to source code
#export_dialog.title=Export
#export_dialog.save_path=Save path:
#export_dialog.browse=Browse
#export_dialog.export_options=Export options
#export_dialog.export_gradle=Export as gradle project
#export_dialog.export_gradle=Export as a Gradle project
#export_dialog.export_gradle_type=Gradle template:
log_viewer.title=Visor log
@@ -288,7 +288,7 @@ preferences.rename_use_source_name_as_class_name_alias=Usar el nombre del source
#preferences.search_group_title=Search
#preferences.search_results_per_page=Results per page (0 - no limit)
#preferences.res_file_ext=Resource files extensions ('xml|html', * for all)
#preferences.res_skip_file=Skip resources files if larger (MB)
#preferences.res_skip_file=Skip resources files if larger (MB) (0 - disable)
#preferences.tab_dnd_appearance=Dragging tab appearance
#preferences.plugins.install=Install plugin
@@ -11,7 +11,7 @@ menu.flatten=Tampilkan paket yang diratakan
menu.heapUsageBar=Tampilkan penggunaan memori
menu.alwaysSelectOpened=Selalu Pilih Berkas/Kelas yang Terbuka
menu.dock_log=Kaitkan pemantau log
#menu.dock_quick_tabs=Dock quick tabs
#menu.dock_quick_tabs=Show Quick Tabs
menu.navigation=Navigasi
menu.text_search=Pencarian Teks
menu.class_search=Pencarian Kelas
@@ -192,11 +192,11 @@ comment_dialog.usage=Gunakan Shift + Enter untuk memulai baris baru
#rename_dialog.class_help=Enter full name to move class to another package. Start with '.' to move to default (empty) package
#export_dialog.title=Export to source code
#export_dialog.title=Export
#export_dialog.save_path=Save path:
#export_dialog.browse=Browse
#export_dialog.export_options=Export options
#export_dialog.export_gradle=Export as gradle project
#export_dialog.export_gradle=Export as a Gradle project
#export_dialog.export_gradle_type=Gradle template:
log_viewer.title=Pemantau Log
@@ -10,8 +10,8 @@ menu.flatten=플랫 패키지 표시
#menu.enable_preview_tab=Enable Preview Tab
menu.heapUsageBar=메모리 사용량 표시
menu.alwaysSelectOpened=항상 열린 파일/클래스 선택
#menu.dock_log=Dock log viewer
#menu.dock_quick_tabs=Dock quick tabs
#menu.dock_log=Dock Log Viewer
#menu.dock_quick_tabs=Show Quick Tabs
menu.navigation=네비게이션
menu.text_search=텍스트 검색
menu.class_search=클래스 검색
@@ -45,7 +45,7 @@ file.live_reload_desc=파일 내용 변경 시 자동으로 다시 로드
#file.open_mappings=Open mappings...
file.save_mappings=다른 이름으로 매핑 내보내기...
#file.save_mappings_as=Save mappings as...
#file.close_mappings=다른 이름으로 매핑 내보내기...
#file.close_mappings=Close mappings
file.save_all=모두 저장
#file.save=Save
#file.export=Export project
@@ -192,11 +192,11 @@ comment_dialog.usage=Shift + Enter 를 입력해 새 라인에 입력
#rename_dialog.class_help=Enter full name to move class to another package. Start with '.' to move to default (empty) package
#export_dialog.title=Export to source code
#export_dialog.title=Export
#export_dialog.save_path=Save path:
#export_dialog.browse=Browse
#export_dialog.export_options=Export options
#export_dialog.export_gradle=Export as gradle project
#export_dialog.export_gradle=Export as a Gradle project
#export_dialog.export_gradle_type=Gradle template:
log_viewer.title=로그 뷰어
@@ -10,8 +10,8 @@ menu.flatten=Mostrar pacotes achatados
#menu.enable_preview_tab=Enable Preview Tab
menu.heapUsageBar=Mostrar uso de memória
menu.alwaysSelectOpened=Sempre selecionar arquivo/classe aberta
#menu.dock_log=Dock log viewer
#menu.dock_quick_tabs=Dock quick tabs
#menu.dock_log=Dock Log Viewer
#menu.dock_quick_tabs=Show Quick Tabs
menu.navigation=Navegação
menu.text_search=Buscar por texto
menu.class_search=Buscar por classe
@@ -192,11 +192,11 @@ comment_dialog.usage=Use Shift + Enter para pular uma linha
#rename_dialog.class_help=Enter full name to move class to another package. Start with '.' to move to default (empty) package
#export_dialog.title=Export to source code
#export_dialog.title=Export
#export_dialog.save_path=Save path:
#export_dialog.browse=Browse
#export_dialog.export_options=Export options
#export_dialog.export_gradle=Export as gradle project
#export_dialog.export_gradle=Export as a Gradle project
#export_dialog.export_gradle_type=Gradle template:
log_viewer.title=Visualizador de log
@@ -11,7 +11,7 @@ menu.flatten=Плоская структура пакетов
menu.heapUsageBar=Использование ОЗУ
menu.alwaysSelectOpened=Выбирать открытый файл/класс
menu.dock_log=Просмотр логов в панели
#menu.dock_quick_tabs=Dock quick tabs
#menu.dock_quick_tabs=Show Quick Tabs
menu.navigation=Навигация
menu.text_search=Поиск строк
menu.class_search=Поиск классов
@@ -192,11 +192,11 @@ comment_dialog.usage=Используйте Shift + Enter для перенос
rename_dialog.class_help=Введите полный путь к пакету, в который вы хотите переместить этот класс. Введите '.' для перемещения в пакет по умолчанию (пустой) пакет
#export_dialog.title=Export to source code
#export_dialog.title=Export
#export_dialog.save_path=Save path:
#export_dialog.browse=Browse
#export_dialog.export_options=Export options
#export_dialog.export_gradle=Export as gradle project
#export_dialog.export_gradle=Export as a Gradle project
#export_dialog.export_gradle_type=Gradle template:
log_viewer.title=Просмотр логов