Updated Iris language files -

All 17 translations now use editable TOML files
Language menus and debug messages include translations
Headers explain all 243 variables, prefixes, colors and formatting
Generated guides refresh without overwriting customized messages
Missing or invalid messages fall back to English
Language reloads preserve server and player selections
Jars include localized guides and required TOML support
This commit is contained in:
Robert Stoneking
2026-09-10 01:17:18 -06:00
parent 62e61970f8
commit 45407a7e3f
45 changed files with 37032 additions and 27536 deletions
+34 -6
View File
@@ -1,4 +1,5 @@
import io.github.slimjar.resolver.data.Mirror
import org.gradle.api.file.FileTreeElement
import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.compile.JavaCompile
@@ -43,20 +44,39 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
String languageSourceReference = providers.gradleProperty('languageRevision').orElse('master').get()
List<File> languageSourceFiles = fileTree('src/main/resources/languages') {
include('*.json')
include('*.toml')
}.files.toList().sort { File left, File right -> left.name <=> right.name }
List<String> languageSourceLocales = languageSourceFiles.collect { File source ->
source.name.substring(0, source.name.length() - '.json'.length())
source.name.substring(0, source.name.length() - '.toml'.length())
}
def languageSourceManifestTask = tasks.register('generateLanguageSourceManifest', WriteProperties) {
destinationFile.set(layout.buildDirectory.file('generated/language-source/iris-language-source.properties'))
property('revision', languageSourceReference)
property('locales', languageSourceLocales.join(','))
}
def languageGuidesTask = tasks.register('generateLanguageGuides') {
inputs.files(languageSourceFiles)
def guideDirectory = layout.buildDirectory.dir('generated/language-guides')
outputs.dir(guideDirectory)
doLast {
File output = guideDirectory.get().asFile
output.mkdirs()
for (File source : languageSourceFiles) {
List<String> header = source.readLines('UTF-8').takeWhile { String line ->
line.isBlank() || line.stripLeading().startsWith('#')
}
String locale = source.name.substring(0, source.name.length() - '.toml'.length())
new File(output, locale + '.txt').setText(header.join('\n').stripTrailing() + '\n', 'UTF-8')
}
}
}
tasks.named('processResources').configure {
dependsOn(languageSourceManifestTask)
dependsOn(languageSourceManifestTask, languageGuidesTask)
exclude('languages/**')
from(languageSourceManifestTask)
from(languageGuidesTask) {
into('iris-language-guides')
}
}
/**
@@ -106,6 +126,9 @@ dependencies {
implementation(volmLibCoordinate) {
transitive = false
}
implementation('com.moandjiezana.toml:toml4j:0.7.2') {
transitive = false
}
// Dynamically Loaded
slim(libs.gson)
slim(libs.lru)
@@ -265,7 +288,6 @@ List<String> supersededVolmLibPackages = [
'art/arcane/volmlib/util/value/**',
'art/arcane/volmlib/util/api/**',
'art/arcane/volmlib/util/entity/**',
'art/arcane/volmlib/util/config/**',
'art/arcane/volmlib/util/reflect/**',
'art/arcane/volmlib/util/documentation/**',
'art/arcane/iris/core/report/**',
@@ -282,8 +304,6 @@ List<String> unusedVolmLibClasses = [
'art/arcane/volmlib/util/nbt/io/StringPointer',
'art/arcane/volmlib/util/io/StringDeserializer',
'art/arcane/volmlib/util/io/StringSerializer',
'art/arcane/volmlib/util/localization/TomlLanguageEditor',
'art/arcane/volmlib/util/localization/TomlLanguageParser',
'art/arcane/volmlib/util/json/EnumType',
'art/arcane/volmlib/util/json/HTTP',
'art/arcane/volmlib/util/json/HTTPTokener',
@@ -414,7 +434,10 @@ List<String> dependencyBuildMetadata = [
'META-INF/versions/*/OSGI-INF/**'
]
String languageTomlSupportClassPattern = 'art/arcane/volmlib/util/config/(?:TomlCodec|ConfigJson|ConfigExposePolicy|ConfigDoc|ConfigDescription|ConfigDocumentation)(?:\\$[^/]+)?\\.class'
tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar).configure {
inputs.property('languageTomlSupportClassPattern', languageTomlSupportClassPattern)
dependsOn(embeddedAgentJar)
mergeServiceFiles()
// minimize() stays off. Caffeine reaches its 520 generated cache and node classes through
@@ -424,8 +447,13 @@ tasks.named('shadowJar', com.github.jengelman.gradle.plugins.shadow.tasks.Shadow
// leaves it reaching ~25KB of Gson internals, which is not worth a mode that fails silently.
relocate('com.dfsek.paralithic', "${lib}.paralithic")
relocate('io.github.slimjar', "${lib}.slimjar")
relocate('com.moandjiezana.toml', "${lib}.toml")
exclude('modules/loader-agent.isolated-jar')
exclude(supersededVolmLibPackages)
exclude { FileTreeElement entry ->
!entry.isDirectory() && entry.path.startsWith('art/arcane/volmlib/util/config/')
&& !entry.path.matches(languageTomlSupportClassPattern)
}
exclude(unusedVolmLibClassEntries)
exclude(annotationOnlyArtifacts)
exclude(dependencyBuildMetadata)
@@ -58,7 +58,7 @@ public final class SettingsHotloadWatch implements AutoCloseable {
) {
this.settingsFile = Objects.requireNonNull(settingsFile, "Settings file cannot be null").getAbsoluteFile();
File dataFolder = Objects.requireNonNull(this.settingsFile.getParentFile(), "Settings data folder cannot be null");
localeOverrideFolder = new File(dataFolder, "languages/overrides").getAbsoluteFile();
localeOverrideFolder = new File(dataFolder, "languages").getAbsoluteFile();
Timing resolvedTiming = Objects.requireNonNull(timing, "Hotload timing cannot be null");
this.beforeSnapshotApply = Objects.requireNonNull(beforeSnapshotApply, "Snapshot apply observer cannot be null");
manualReloadListener = this::acknowledgeManualLocaleReload;
@@ -121,12 +121,12 @@ public final class SettingsHotloadWatch implements AutoCloseable {
}
return applySettingsSnapshot(file, snapshot.normalizedContent());
}
if (!isLocaleOverrideFile(file) || !IrisLanguage.isActiveOverrideFile(file)) {
if (!isLocaleOverrideFile(file) || !IrisLanguage.isLanguageFile(file)) {
return true;
}
if (!missing && snapshot.normalizedContent() == null) {
reportUnavailableSnapshot(file);
return false;
return applyLocaleSnapshot(file, null);
}
return applyLocaleSnapshot(file, missing ? null : snapshot.normalizedContent());
}
@@ -136,7 +136,7 @@ public final class SettingsHotloadWatch implements AutoCloseable {
}
boolean isLocaleOverrideFile(File file) {
if (file == null || !file.getName().toLowerCase(Locale.ROOT).endsWith(".json")) {
if (file == null || !file.getName().toLowerCase(Locale.ROOT).endsWith(".toml")) {
return false;
}
File parent = file.getAbsoluteFile().getParentFile();
@@ -262,8 +262,8 @@ public final class SettingsHotloadWatch implements AutoCloseable {
}
return;
}
if (IrisLanguage.isActiveOverrideFile(file)) {
IrisLogging.debug("Hotloaded locale override " + file.getName());
if (IrisLanguage.isLanguageFile(file)) {
IrisLogging.debug("Hotloaded language file " + file.getName());
}
}
@@ -24,25 +24,23 @@ import art.arcane.volmlib.util.localization.MessageArgs;
import art.arcane.volmlib.util.localization.MessageCatalog;
import art.arcane.volmlib.util.localization.MessageKey;
import art.arcane.volmlib.util.localization.MessageValue;
import art.arcane.volmlib.util.localization.LinesValue;
import art.arcane.volmlib.util.localization.PluralValue;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.PluralKey;
import art.arcane.volmlib.util.localization.PluralSelector;
import art.arcane.volmlib.util.localization.ResolvedLines;
import art.arcane.volmlib.util.localization.ResolvedText;
import art.arcane.volmlib.util.localization.TextKey;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import art.arcane.volmlib.util.localization.TomlLanguageEditor;
import art.arcane.volmlib.util.localization.TomlLanguageParser;
import art.arcane.volmlib.util.localization.TomlLanguageWriter;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.GsonBuilder;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Path;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.LinkOption;
import java.nio.file.StandardCopyOption;
import java.util.LinkedHashSet;
import java.util.Set;
@@ -158,7 +156,16 @@ public final class IrisLanguage {
return false;
}
try {
createEnglishLanguageIfMissing(resolvedRoot);
} catch (IOException failure) {
IrisLogging.error("Could not create the editable Iris English language file.");
IrisLogging.reportError(failure);
}
File override = overrideFile(resolvedRoot, requestedLocale);
if (!CATALOG.englishLocale().equals(requestedLocale)) {
refreshLanguageGuide(override, requestedLocale);
}
SnapshotCapture capture = captureForReload(override, requestedLocale);
boolean applied = applyReload(resolvedRoot, requestedLocale, capture);
if (applied && notifyManualReload) {
@@ -176,12 +183,14 @@ public final class IrisLanguage {
return false;
}
String configured = configuredLocale();
String requestedLocale;
try {
requestedLocale = normalizeLocale(configured);
if (!isLanguageFile(override)) {
return false;
}
requestedLocale = normalizeLocale(override.getName().substring(0, override.getName().length() - 5));
} catch (RuntimeException exception) {
IrisLogging.error("Rejected locale setting '" + configured + "'; continuing with " + activeLocale + ".");
IrisLogging.error("Rejected Iris language file " + override + ".");
IrisLogging.reportError(exception);
return false;
}
@@ -199,7 +208,34 @@ public final class IrisLanguage {
rawContent,
sha256(rawContent.getBytes(StandardCharsets.UTF_8))
);
return applyReload(root.getAbsoluteFile(), requestedLocale, new SnapshotCapture(snapshot, null));
if (requestedLocale.equals(activeLocale)) {
return applyReload(root.getAbsoluteFile(), requestedLocale, new SnapshotCapture(snapshot, null));
}
try {
LocalizationSnapshot prepared = LocalizationSnapshot.create(loadCandidate(root, requestedLocale, snapshot));
PluginLanguageService current = selections;
if (current != null) {
current.commitUpdate(() -> {
current.cache(requestedLocale, prepared);
return null;
});
}
return true;
} catch (Exception failure) {
IrisLogging.error("Could not reload Iris language " + requestedLocale + ".");
IrisLogging.reportError(failure);
return false;
}
}
public static boolean isLanguageFile(File file) {
File folder = overrideFolder();
if (file == null || folder == null || !file.getName().endsWith(".toml")) {
return false;
}
File target = file.getAbsoluteFile();
String locale = target.getName().substring(0, target.getName().length() - 5);
return folder.getAbsoluteFile().equals(target.getParentFile()) && LOCALE_NAME.matcher(locale).matches();
}
public static boolean isActiveOverrideFile(File file) {
@@ -214,35 +250,35 @@ public final class IrisLanguage {
return false;
}
File overrideRoot = root;
return CapabilityProbe.attempt("locale override path",
return CapabilityProbe.attempt("language file path",
() -> overrideFile(overrideRoot, configuredLocale()).equals(file.getAbsoluteFile()),
Boolean.FALSE);
}
private static boolean applyReload(File root, String requestedLocale, SnapshotCapture capture) {
LocalizationReloadResult result;
synchronized (SNAPSHOT_LOCK) {
if (capture.failure() == null) {
result = MANAGER.reload(() -> loadCandidate(root, requestedLocale, capture.snapshot()));
PluginLanguageService current = selections;
try {
if (capture.failure() != null) {
throw capture.failure();
}
LocalizationSnapshot prepared = LocalizationSnapshot.create(loadCandidate(root, requestedLocale, capture.snapshot()));
if (current == null) {
result = installSnapshot(root, requestedLocale, prepared, null);
} else {
result = MANAGER.reload(() -> {
throw capture.failure();
});
}
dataFolder = root;
if (result.applied()) {
activeLocale = requestedLocale;
result = current.commitUpdate(() -> installSnapshot(root, requestedLocale, prepared, current));
}
} catch (Exception failure) {
IrisLogging.error("Rejected locale reload for " + requestedLocale + "; continuing with " + activeLocale + ".");
IrisLogging.reportError(failure);
return false;
}
if (!result.applied()) {
reportRejectedReload(requestedLocale, result);
return false;
}
PluginLanguageService current = selections;
if (current != null) {
current.invalidate();
current.cache(activeLocale, MANAGER.snapshot());
requestConfiguredLocale();
}
int warnings = result.validation().warnings().size();
@@ -251,6 +287,22 @@ public final class IrisLanguage {
return true;
}
private static LocalizationReloadResult installSnapshot(File root, String locale,
LocalizationSnapshot prepared, PluginLanguageService current) {
LocalizationReloadResult result;
synchronized (SNAPSHOT_LOCK) {
result = MANAGER.install(prepared);
dataFolder = root;
if (result.applied()) {
activeLocale = locale;
}
}
if (result.applied() && current != null) {
current.cache(locale, prepared);
}
return result;
}
private static void notifyManualReload(LocaleHotloadSnapshot snapshot) {
for (BiConsumer<File, String> listener : MANUAL_RELOAD_LISTENERS) {
try {
@@ -276,7 +328,7 @@ public final class IrisLanguage {
if (root == null) {
return null;
}
return new File(root, "languages/overrides");
return new File(root, "languages");
}
public static String text(MessageKey key, MessageArgument... arguments) {
@@ -369,19 +421,10 @@ public final class IrisLanguage {
String locale,
LocaleHotloadSnapshot snapshot
) throws Exception {
File folder = new File(root, "languages/overrides");
Files.createDirectories(folder.toPath());
List<LocaleOverlay> overlays = new ArrayList<>(2);
List<LocaleOverlay> overlays = new ArrayList<>(1);
if (!snapshot.missing()) {
overlays.add(parseOverlay(snapshot.file().getPath(), locale, snapshot.content()));
}
if (!CATALOG.englishLocale().equals(locale)) {
LocaleOverlay downloaded = loadDownloadedOverlay(root, locale);
if (downloaded != null) {
overlays.add(downloaded);
}
}
return new LocalizationCandidate(CATALOG, overlays, PluralSelector.oneOther());
}
@@ -402,9 +445,8 @@ public final class IrisLanguage {
"Iris",
URI.create("https://raw.githubusercontent.com/VolmitSoftware/Iris/"),
"core/src/main/resources/languages",
".json",
".toml",
"iris-language-source.properties",
path.resolve("languages/downloaded"),
IrisLanguage.class.getClassLoader()
));
remoteRoot = path;
@@ -417,7 +459,7 @@ public final class IrisLanguage {
return;
}
selections = new PluginLanguageService(new PluginLanguageService.Options(
dataFolder.toPath().resolve("languages/players.properties"),
dataFolder.toPath().resolve("languages/language-preferences.properties"),
IrisLanguage::availableLocales,
IrisLanguage::activeLocale,
MANAGER::snapshot,
@@ -451,7 +493,18 @@ public final class IrisLanguage {
public static Set<String> availableLocales() {
Set<String> locales = new LinkedHashSet<>();
locales.add(CATALOG.englishLocale());
locales.addAll(remote(dataFolder).availableLocales());
File root = dataFolder;
if (root != null) {
locales.addAll(remote(root).availableLocales());
File[] files = new File(root, "languages").listFiles();
if (files != null) {
for (File file : files) {
if (file.isFile() && isLanguageFile(file)) {
locales.add(file.getName().substring(0, file.getName().length() - 5));
}
}
}
}
return Set.copyOf(locales);
}
@@ -470,8 +523,18 @@ public final class IrisLanguage {
private static LocalizationSnapshot prepareLocale(String locale) throws Exception {
File root = dataFolder;
if (!CATALOG.englishLocale().equals(locale) && remote(root).availableLocales().contains(locale)) {
remote(root).readOrDownload(locale, IrisLanguage::validateDownload);
createEnglishLanguageIfMissing(root);
File file = overrideFile(root, locale);
if (!CATALOG.englishLocale().equals(locale) && !Files.exists(file.toPath(), LinkOption.NOFOLLOW_LINKS)) {
try {
remote(root).readOrInstall(locale, file.toPath(), IrisLanguage::validateDownload);
} catch (Exception failure) {
IrisLogging.error("Could not install Iris language " + locale + "; using English.");
IrisLogging.reportError(failure);
}
}
if (!CATALOG.englishLocale().equals(locale)) {
refreshLanguageGuide(file, locale);
}
SnapshotCapture capture = captureForReload(overrideFile(root, locale), locale);
if (capture.failure() != null) {
@@ -485,6 +548,13 @@ public final class IrisLanguage {
}
private static LocalizationSnapshot writeMessage(PluginLanguageEditor.Edit edit) throws IOException {
LocaleOverlay replacement = LocaleOverlay.builder("editor", edit.locale())
.put(edit.key(), edit.value()).build();
try {
LocalizationValidator.validate(CATALOG, List.of(replacement)).throwIfInvalid();
} catch (IllegalArgumentException failure) {
throw new IOException("Invalid language message: " + edit.key(), failure);
}
File root = dataFolder;
File file = overrideFile(root, edit.locale());
LocalizationSnapshot prepared = LanguageFileEditor.update(file.toPath(), raw -> {
@@ -492,7 +562,7 @@ public final class IrisLanguage {
if (!current.value(CATALOG.require(edit.key())).equals(edit.expected())) {
throw new IOException("Language message changed; reopen it before saving");
}
String updated = writeOverride(raw, edit);
String updated = TomlLanguageEditor.upsert(raw, edit.key(), edit.value()).content();
return new LanguageFileEditor.Prepared<>(updated, editorSnapshot(root, file, edit.locale(), updated));
});
synchronized (SNAPSHOT_LOCK) {
@@ -500,6 +570,10 @@ public final class IrisLanguage {
MANAGER.install(prepared);
}
}
PluginLanguageService current = selections;
if (current != null && root.equals(dataFolder)) {
current.cache(edit.locale(), prepared);
}
return prepared;
}
@@ -512,25 +586,6 @@ public final class IrisLanguage {
}
}
private static String writeOverride(String raw, PluginLanguageEditor.Edit edit) {
Map<String, MessageValue> values = new LinkedHashMap<>(parseOverlay("editor", edit.locale(), raw).values());
values.put(edit.key(), edit.value());
JsonObject messages = new JsonObject();
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
for (Map.Entry<String, MessageValue> entry : values.entrySet()) {
JsonElement value = switch (entry.getValue()) {
case TextValue text -> gson.toJsonTree(text.template());
case LinesValue lines -> gson.toJsonTree(lines.lines());
case PluralValue plural -> gson.toJsonTree(plural.forms());
};
messages.add(entry.getKey(), value);
}
JsonObject document = new JsonObject();
document.addProperty("locale", edit.locale());
document.add("messages", messages);
return gson.toJson(document) + "\n";
}
private static void selectDefault(String locale, LocalizationSnapshot prepared) throws Exception {
IrisSettings current = IrisSettings.get();
JsonObject serialized = new Gson().toJsonTree(current).getAsJsonObject();
@@ -556,7 +611,10 @@ public final class IrisLanguage {
return;
}
File root = dataFolder;
remote(root).request(locale, IrisLanguage::validateDownload, result -> {
if (root == null) {
return;
}
remote(root).requestInstallIfMissing(locale, overrideFile(root, locale).toPath(), IrisLanguage::validateDownload, result -> {
if (!result.successful()) {
IrisLogging.error("Failed to download Iris locale " + locale + ".");
IrisLogging.reportError(result.failure());
@@ -573,108 +631,66 @@ public final class IrisLanguage {
static void validateDownload(String locale, String raw) {
LocaleOverlay overlay = parseDownloadedOverlay("download:" + locale, locale, raw);
LocalizationValidator.validate(CATALOG, List.of(overlay)).throwIfInvalid();
for (MessageKey key : CATALOG.keys()) {
if (overlay.value(key.id()) == null) {
throw new IllegalArgumentException("Downloaded Iris locale " + locale + " is incomplete: " + key.id());
}
}
}
private static LocaleOverlay loadDownloadedOverlay(File root, String locale) {
RemoteLanguageCatalog.CacheResult cached = remote(root).read(locale, IrisLanguage::validateDownload);
if (cached.state() == RemoteLanguageCatalog.CacheState.VALID) {
return parseDownloadedOverlay(cached.file().toString(), locale, cached.content());
}
if (cached.failure() != null) {
IrisLogging.error("Ignoring invalid downloaded Iris locale " + locale + ".");
IrisLogging.reportError(cached.failure());
}
return null;
}
static LocaleOverlay parseDownloadedOverlay(String source, String locale, String raw) {
LocaleOverlay parsed = parseOverlay(source, locale, raw);
LocaleOverlay.Builder builder = LocaleOverlay.builder(source, locale);
for (Map.Entry<String, MessageValue> entry : parsed.values().entrySet()) {
if (CATALOG.key(entry.getKey()) != null) {
try {
for (Map.Entry<String, MessageValue> entry : TomlLanguageParser.parseValidValues(raw, CATALOG).entrySet()) {
builder.put(entry.getKey(), entry.getValue());
}
} catch (IOException failure) {
throw new IllegalArgumentException("Invalid TOML language file: " + source, failure);
}
return builder.build();
return LocalizationValidator.validValues(CATALOG, builder.build());
}
static LocaleOverlay parseOverlay(String source, String locale, String raw) {
JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw);
if (!parsed.isJsonObject()) {
throw new IllegalArgumentException("Locale source is not a JSON object: " + source);
}
JsonObject root = parsed.getAsJsonObject();
for (String key : root.keySet()) {
if (!key.equals("locale") && !key.equals("messages")) {
throw new IllegalArgumentException("Unknown locale root key: " + key);
}
}
if (root.has("locale")) {
JsonElement declaredLocale = root.get("locale");
if (!declaredLocale.isJsonPrimitive() || !locale.equals(normalizeLocale(declaredLocale.getAsString()))) {
throw new IllegalArgumentException("Locale source declares a different locale than its file: " + source);
}
}
LocaleOverlay.Builder builder = LocaleOverlay.builder(source, locale);
if (!root.has("messages")) {
return builder.build();
}
JsonElement messages = root.get("messages");
if (!messages.isJsonObject()) {
throw new IllegalArgumentException("Locale messages must be a JSON object: " + source);
}
appendMessages(builder, messages.getAsJsonObject(), "");
return builder.build();
}
private static void appendMessages(LocaleOverlay.Builder builder, JsonObject object, String prefix) {
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey();
JsonElement value = entry.getValue();
if (value == null || value.isJsonNull()) {
throw new IllegalArgumentException("Locale value cannot be null: " + key);
}
MessageKey definition = CATALOG.key(key);
if (value.isJsonObject() && definition instanceof PluralKey) {
builder.plural(key, readPlural(key, value.getAsJsonObject()));
} else if (value.isJsonObject()) {
appendMessages(builder, value.getAsJsonObject(), key);
} else if (value.isJsonArray()) {
builder.lines(key, readLines(key, value.getAsJsonArray()));
} else if (value.isJsonPrimitive() && value.getAsJsonPrimitive().isString()) {
builder.text(key, value.getAsString());
} else {
throw new IllegalArgumentException("Locale value must be text, lines, or plural forms: " + key);
}
try {
return parseDownloadedOverlay(source, locale, raw);
} catch (IllegalArgumentException failure) {
IrisLogging.error("Using English for unreadable Iris language file " + source + ".");
IrisLogging.reportError(failure);
return LocaleOverlay.builder(source, locale).build();
}
}
private static List<String> readLines(String key, JsonArray array) {
List<String> lines = new ArrayList<>(array.size());
for (JsonElement value : array) {
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("Locale line must be text: " + key);
}
lines.add(value.getAsString());
static String englishReference() {
Map<String, MessageValue> values = new LinkedHashMap<>();
for (MessageKey key : CATALOG.keys()) {
values.put(key.id(), key.englishValue());
}
return lines;
return IrisLanguageGuide.englishHeader(CATALOG.englishLocale()) + "\n"
+ TomlLanguageWriter.render(values, List.of());
}
private static Map<String, String> readPlural(String key, JsonObject object) {
Map<String, String> forms = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : object.entrySet()) {
JsonElement value = entry.getValue();
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
throw new IllegalArgumentException("Locale plural form must be text: " + key + "." + entry.getKey());
private static void refreshLanguageGuide(File file, String locale) {
try {
IrisLanguageGuide.refresh(file, locale);
} catch (IOException failure) {
IrisLogging.error("Could not update the language guide in " + file.getPath() + ".");
IrisLogging.reportError(failure);
}
}
private static void createEnglishLanguageIfMissing(File root) throws IOException {
Path target = overrideFile(Objects.requireNonNull(root, "Language data folder"), CATALOG.englishLocale()).toPath();
if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) {
refreshLanguageGuide(target.toFile(), CATALOG.englishLocale());
return;
}
Files.createDirectories(target.getParent());
Path temporary = Files.createTempFile(target.getParent(), "en_US-", ".toml.tmp");
try {
Files.writeString(temporary, englishReference(), StandardCharsets.UTF_8);
try {
Files.move(temporary, target);
} catch (FileAlreadyExistsException existing) {
return;
}
forms.put(entry.getKey(), value.getAsString());
} finally {
Files.deleteIfExists(temporary);
}
return forms;
}
private static String render(ResolvedText resolved) {
@@ -744,20 +760,88 @@ public final class IrisLanguage {
if (value == null || value.isEmpty()) {
return "";
}
char[] characters = value.toCharArray();
for (int index = 0; index < characters.length - 1; index++) {
if (characters[index] == '&' && isColorCode(characters[index + 1])) {
characters[index] = '\u00a7';
characters[index + 1] = Character.toLowerCase(characters[index + 1]);
if (value.indexOf('&') < 0 && value.indexOf('[') < 0) {
return value;
}
StringBuilder output = new StringBuilder(value.length() + 16);
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (current == '\\' && index + 1 < value.length()
&& (value.charAt(index + 1) == '&' || value.charAt(index + 1) == '[')) {
output.append(value.charAt(++index));
continue;
}
if (current == '[' && index + 7 < value.length() && value.charAt(index + 7) == ']'
&& isHex(value, index + 1)) {
appendHexColor(output, value.substring(index + 1, index + 7));
index += 7;
continue;
}
if (current == '&' && index + 1 < value.length()) {
char code = Character.toLowerCase(value.charAt(index + 1));
if ((code == '#' || code == 'x') && isHex(value, index + 2)) {
appendHexColor(output, value.substring(index + 2, index + 8));
index += 7;
continue;
}
if (code == 'x') {
String expanded = expandedHex(value, index);
if (expanded != null) {
appendHexColor(output, expanded);
index += 13;
continue;
}
}
if (isColorCode(code)) {
output.append('\u00a7').append(code);
index++;
continue;
}
}
output.append(current);
}
return output.toString();
}
private static boolean isHex(String value, int offset) {
if (offset + 6 > value.length()) {
return false;
}
for (int index = offset; index < offset + 6; index++) {
if (Character.digit(value.charAt(index), 16) < 0) {
return false;
}
}
return new String(characters);
return true;
}
private static String expandedHex(String value, int offset) {
if (offset + 14 > value.length()) {
return null;
}
StringBuilder hex = new StringBuilder(6);
for (int index = 0; index < 6; index++) {
int marker = offset + 2 + index * 2;
char digit = value.charAt(marker + 1);
if (value.charAt(marker) != '&' || Character.digit(digit, 16) < 0) {
return null;
}
hex.append(digit);
}
return hex.toString();
}
private static void appendHexColor(StringBuilder output, String hex) {
output.append('\u00a7').append('x');
for (int index = 0; index < hex.length(); index++) {
output.append('\u00a7').append(Character.toLowerCase(hex.charAt(index)));
}
}
private static boolean isColorCode(char value) {
char lowered = Character.toLowerCase(value);
return lowered >= '0' && lowered <= '9' || lowered >= 'a' && lowered <= 'f'
|| lowered >= 'k' && lowered <= 'o' || lowered == 'r' || lowered == 'x';
|| lowered >= 'k' && lowered <= 'o' || lowered == 'r';
}
private static String escapeUntrusted(String value) {
@@ -781,11 +865,11 @@ public final class IrisLanguage {
}
private static File overrideFile(File root, String locale) {
return new File(new File(root, "languages/overrides"), normalizeLocale(locale) + ".json").getAbsoluteFile();
return new File(new File(root, "languages"), normalizeLocale(locale) + ".toml").getAbsoluteFile();
}
static LocaleHotloadSnapshot captureHotloadSnapshot(File file, String locale) throws IOException {
File resolvedFile = Objects.requireNonNull(file, "Locale override file cannot be null").getAbsoluteFile();
File resolvedFile = Objects.requireNonNull(file, "Language file cannot be null").getAbsoluteFile();
String resolvedLocale = normalizeLocale(locale);
BasicFileAttributes before;
try {
@@ -794,10 +878,10 @@ public final class IrisLanguage {
return LocaleHotloadSnapshot.missing(resolvedFile, resolvedLocale);
}
if (!before.isRegularFile()) {
throw new IllegalArgumentException("Locale override is not a regular file: " + resolvedFile.getPath());
throw new IllegalArgumentException("Language file is not a regular file: " + resolvedFile.getPath());
}
if (before.size() > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath());
throw new IllegalArgumentException("Language file is too large: " + resolvedFile.getPath());
}
byte[] bytes;
@@ -807,7 +891,7 @@ public final class IrisLanguage {
return null;
}
if (bytes.length > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Locale override is too large: " + resolvedFile.getPath());
throw new IllegalArgumentException("Language file is too large: " + resolvedFile.getPath());
}
BasicFileAttributes after;
@@ -824,7 +908,7 @@ public final class IrisLanguage {
try {
content = StandardCharsets.UTF_8.newDecoder().decode(ByteBuffer.wrap(bytes)).toString();
} catch (CharacterCodingException failure) {
throw new IOException("Locale override is not valid UTF-8: " + resolvedFile.getPath(), failure);
throw new IOException("Language file is not valid UTF-8: " + resolvedFile.getPath(), failure);
}
return LocaleHotloadSnapshot.present(resolvedFile, resolvedLocale, content, sha256(bytes));
}
@@ -835,12 +919,15 @@ public final class IrisLanguage {
if (snapshot == null) {
return new SnapshotCapture(
null,
new IOException("Locale override changed while being read: " + file.getPath())
new IOException("Language file changed while being read: " + file.getPath())
);
}
return new SnapshotCapture(snapshot, null);
} catch (Exception failure) {
return new SnapshotCapture(null, failure);
IrisLogging.error("Using English for unreadable Iris language file " + file.getPath() + ".");
IrisLogging.reportError(failure);
return new SnapshotCapture(LocaleHotloadSnapshot.present(
file, locale, "", sha256(new byte[0])), null);
}
}
@@ -0,0 +1,411 @@
package art.arcane.iris.core.localization;
import art.arcane.volmlib.util.localization.LanguageFileEditor;
import art.arcane.volmlib.util.localization.LanguageFileHeader;
import art.arcane.volmlib.util.localization.TomlLanguageWriter;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
final class IrisLanguageGuide {
private static final String START = "# === Iris language guide ===";
private static final String END = "# === End Iris language guide ===";
private static final Pattern UNDESCRIBED_VARIABLES = Pattern.compile("#\\s+(?:\\{[A-Za-z][A-Za-z0-9_]*}\\s*)+");
private static final Map<String, String> UNMARKED_GUIDE_FINGERPRINTS = Map.ofEntries(
Map.entry("de_DE", "c32159c55e437c3e0e638a48618d2c4ea3bcefebacd3883e94095c986fb9a7e6"),
Map.entry("en_US", "db2ebc5f392c7cd97ca0c3b58e7d9b75bd591b949f9adf9913ebd1053f5d8062"),
Map.entry("es_ES", "2981096400cedaa8b2414c0e82f4476f6f4c45aa503005f852536342bd4a3f29"),
Map.entry("fi_FI", "cfb3b61055061ce5a414b03a90a04c0b0e7cfdd10b5fea717a42a2d75da73824"),
Map.entry("fr_FR", "caec157e7708cd30e0fc79a7fdb8b5468c2886e1e99a9d46a29b7dcfb0411b33"),
Map.entry("he_IL", "5b21aa76fa8c5bf5c1c34e6855050c86321a886cf8d99383ae942b5198669f1b"),
Map.entry("it_IT", "fbef45fa6c4f2edb6ce9c5dcd2c599c238ddec87ae69410a79a6d753cdebab35"),
Map.entry("ja-JP", "59ab056560b61de78f4423153ae153d3ed7e8d68f4312c178617386f81164ae5"),
Map.entry("ko_KR", "ab2cb82ad53b50c820b92c2fad4d4dc93e959c7a72429f9fa33c9768ab1965de"),
Map.entry("lt_LT", "5493f682426006798f8e732157f3af750c092b181302e8879e438f75c0cb160a"),
Map.entry("nl_NL", "5dbcd5250753367801615d8b5603f8027f51e5db14f27c7ed3198fae36177e05"),
Map.entry("pl_PL", "ef896ad5ccc8a7c9507fd7012443c0ff3395d27ef179f8eaf1455e5339a0c6c6"),
Map.entry("pt_PT", "98a1fda8484ccb2afc00225b65d5bdc1f9cc0db286709c19cd5aad5d457fa024"),
Map.entry("ru_RU", "b6eb34594096aeab22dae072e74f186611a26b5da02d70896721f78157f30058"),
Map.entry("tr_TR", "295294d1087ec5c1a2c7e1cabd7dc4495c323cdebee300d31d39103680fa48ae"),
Map.entry("vi_VI", "fd2a3fb60cd84fba4d8da7269207f74db7b33cbf49e0d0d6f22fcb5c0a18f174"),
Map.entry("zh_CN", "5e98574730c2c653f41431efcec5a4f8c6398e7d2c0fd10bfacd1f7f0f3a8948"),
Map.entry("zh_TW", "c479ac67abb68400a3935e0fc745d510fd6bd1c257960ca0c370a58c5309b06d")
);
private static final Map<String, String> VARIABLE_DESCRIPTIONS = Map.ofEntries(
Map.entry("MAXAUTOSELECTVOLUME", "Maximum number of blocks allowed in an automatically selected object."),
Map.entry("MAXSAVEVOLUME", "Maximum number of blocks allowed in a saved object selection."),
Map.entry("MAXSPAN", "Maximum allowed structure width, height or depth in blocks during capture."),
Map.entry("NATIVESTRUCTURELOCATERADIUS", "Maximum native-structure search radius in chunks."),
Map.entry("ROOTPERMISSION", "Root permission required to use the command."),
Map.entry("WORKSPACENAME", "Name of the Studio workspace folder being searched."),
Map.entry("WORLDPACKNAME", "Name of the generated world datapack."),
Map.entry("active", "Number of workers currently running."),
Map.entry("activeLocale", "Language that remains active when the requested language cannot be selected."),
Map.entry("activeMax", "Upper boundary of the loaded dimension type's height range."),
Map.entry("activeMin", "Lower boundary of the loaded dimension type's height range."),
Map.entry("actualReverts", "Number of object pastes actually reverted."),
Map.entry("afb", "Formatted part of an engine timing label after its bracketed portion."),
Map.entry("after", "Value after the change, or remaining unreachable tree blocks after repair."),
Map.entry("amount", "Quantity associated with the named entry in the report."),
Map.entry("applied", "Number of chunks whose work has been applied."),
Map.entry("argument", "Command argument that could not be accepted."),
Map.entry("available", "Number of native structures eligible for generation."),
Map.entry("bar", "Already formatted progress-bar text."),
Map.entry("befb", "Formatted part of an engine timing label before its bracketed portion."),
Map.entry("before", "Value before the change, or unreachable tree blocks before repair."),
Map.entry("biome", "Name or identifier of the biome being shown."),
Map.entry("block", "Block type or block description being inspected."),
Map.entry("blockX", "X coordinate of the block or teleport destination."),
Map.entry("blockY", "Y coordinate of the block or teleport destination."),
Map.entry("blockZ", "Z coordinate of the block or teleport destination."),
Map.entry("blocking", "Number of validation errors that prevent the pack from loading."),
Map.entry("blocks", "Number of block variants in the imported group."),
Map.entry("bpp", "World blocks represented by one pixel in Vision."),
Map.entry("branches", "Number of branches added while repairing tree objects."),
Map.entry("broken", "Number of packs that failed validation."),
Map.entry("brokenTotal", "Total number of packs that failed validation."),
Map.entry("cave", "Cave biome or cave description at the inspected position."),
Map.entry("centerX", "X coordinate of the area's center; units follow the operation's message."),
Map.entry("centerZ", "Z coordinate of the area's center; units follow the operation's message."),
Map.entry("changed", "Number of processed objects that were changed."),
Map.entry("chunk", "Coordinates or identifying label of the chunk being reported."),
Map.entry("chunkX", "X coordinate measured in chunks, not blocks."),
Map.entry("chunkZ", "Z coordinate measured in chunks, not blocks."),
Map.entry("chunks", "Number of chunks included in the operation."),
Map.entry("clampedY", "Teleport destination Y after adjustment to the allowed height range."),
Map.entry("command", "Command name or complete command to run."),
Map.entry("converted", "Number of leaf blocks converted into wood during tree repair."),
Map.entry("count", "Number of entries, messages or players identified by the surrounding text."),
Map.entry("current", "Current progress count, or number of chunks in the current comparison."),
Map.entry("currentStage", "Name of the current Studio loading stage."),
Map.entry("d", "Object depth in blocks."),
Map.entry("depth", "Number of blocks below the terrain surface."),
Map.entry("derivative", "Biome derivative or variant associated with the surface biome."),
Map.entry("detail", "Additional description of the inspected object or current progress stage."),
Map.entry("diffs", "Number of differences detected between generated results."),
Map.entry("dimKey", "Identifier of the dimension being packaged."),
Map.entry("dimension", "Name or identifier of the dimension involved in the message."),
Map.entry("dimensionId", "Registered identifier of the loaded or requested dimension."),
Map.entry("dimensionId2", "Dimension identifier repeated in the suggested delete command."),
Map.entry("dimm", "Studio dimension identifier, or its pack name in the validation command."),
Map.entry("disabled", "Number of native structures whose generation is disabled."),
Map.entry("distances", "Number of leaf-distance values changed during tree repair."),
Map.entry("done", "Number of work items completed so far."),
Map.entry("downloadSource", "Source or selected reference used to download the pack."),
Map.entry("downloadable", "Pack or downloadable project being imported."),
Map.entry("dumpPath", "Location of the saved cleanup or restore data."),
Map.entry("duration", "Formatted duration of the operation."),
Map.entry("e", "Exception text from a failed structure-template scan."),
Map.entry("elapsed", "Formatted time elapsed since the operation started."),
Map.entry("engines", "Number of active Iris generation engines or dimensions."),
Map.entry("entity", "Entity type selected by the inspected spawner."),
Map.entry("error", "Details of the error that occurred."),
Map.entry("errorMessage", "Optional error-detail suffix appended to the main failure message."),
Map.entry("eta", "Formatted estimated time remaining; some messages supply it with its label."),
Map.entry("failed", "Number of failed items, or a prepared failure suffix in progress titles."),
Map.entry("failure", "Reason the search or operation failed."),
Map.entry("failures", "Failed-work count or an optional formatted failure summary."),
Map.entry("file", "Name or path of the file involved in the operation."),
Map.entry("filter", "Text used to filter the list of Iris dimensions."),
Map.entry("fps", "Current preview rendering rate in frames per second."),
Map.entry("g", "Object files written since the previous packaging progress report."),
Map.entry("generated", "Number of chunks generated so far."),
Map.entry("generator", "Name of the noise generator shown in the explorer."),
Map.entry("generatorKey", "Requested noise-generator identifier."),
Map.entry("get", "Number of schematics successfully converted."),
Map.entry("golden", "Number of chunks in the saved reference comparison."),
Map.entry("goldenDimension", "Dimension identifier stored in the reference capture."),
Map.entry("goldenSeed", "World seed stored in the reference capture."),
Map.entry("goldenVersion", "Minecraft version used to create the reference capture."),
Map.entry("group", "Message group in the language editor, or an imported object's category."),
Map.entry("guiNote", "Optional note about the pregeneration progress window."),
Map.entry("h", "Object height in blocks."),
Map.entry("hash", "Combined checksum of the generated chunks being compared."),
Map.entry("health", "Current health of the inspected entity."),
Map.entry("height", "Terrain height, total height or displayed area's height, as named in the message."),
Map.entry("hours", "Hours component of the formatted duration."),
Map.entry("i", "Engine display name, or a listed entry in the command-list template."),
Map.entry("id", "Registry ID of the biome being inspected."),
Map.entry("imported", "Number of structures or objects successfully imported."),
Map.entry("includeNonJigsaw", "Whether the import also includes structures that do not use jigsaw pieces."),
Map.entry("iris", "Number of loaded dimensions using Iris generation."),
Map.entry("irisLevels", "Number of loaded Iris dimensions checked against pack heights."),
Map.entry("irisPlaced", "Number of structures placed by Iris instead of native generation."),
Map.entry("job", "Name of the completed background operation."),
Map.entry("k", "Region load key in the region-distribution report."),
Map.entry("key", "Parameter, message or resource identifier named by the surrounding text."),
Map.entry("key2", "Structure identifier repeated in the suggested locate command."),
Map.entry("keyString", "Structure or resource identifier being imported or reported as invalid."),
Map.entry("label", "Display name of the located target."),
Map.entry("line", "Line number in the language editor."),
Map.entry("locale", "Language identifier, such as en_US or de_DE."),
Map.entry("logicalWorldName", "Logical name of the world targeted by the command."),
Map.entry("loot", "Identifier of the inspected loot table."),
Map.entry("m", "Extra evacuation message, or ignored-parameter text in that command template."),
Map.entry("mantleStatus", "Result of resetting generation data before a repeat-generation comparison."),
Map.entry("marker", "Name of the marker being searched for."),
Map.entry("material", "Minecraft material of the inspected block."),
Map.entry("maxScale", "Largest allowed object scale after clamping the requested scale."),
Map.entry("maxX", "Upper X boundary of the displayed chunk range."),
Map.entry("maxY", "Upper boundary of the world's height range."),
Map.entry("maxZ", "Upper Z boundary of the displayed chunk range."),
Map.entry("maximum", "Maximum permitted input length or maximum entity health, as named in the message."),
Map.entry("mcmeta", "Path of the generated datapack's pack.mcmeta file."),
Map.entry("message", "Detailed status or error text supplied by the operation."),
Map.entry("method", "Selected pregeneration method."),
Map.entry("minX", "Lower X boundary of the displayed chunk range."),
Map.entry("minY", "Lower boundary of the world's height range."),
Map.entry("minZ", "Lower Z boundary of the displayed chunk range."),
Map.entry("minutes", "Minutes component of the formatted duration."),
Map.entry("mismatches", "Number of chunks that differ from the saved reference."),
Map.entry("mode", "Selected import, generation or preview mode."),
Map.entry("modeNote", "Optional explanation of the selected pregeneration mode."),
Map.entry("name", "Name of the world, object, project or entry identified by the message."),
Map.entry("nameRaw", "Name exactly as entered, before normalization or validation."),
Map.entry("normalized", "Normalized help-section name that could not be found."),
Map.entry("num", "Formatted bracketed portion of an engine timing label; not necessarily a number."),
Map.entry("object", "Name or identifier of the Iris object being edited or inspected."),
Map.entry("objects", "Object list or count for the inspected chunk."),
Map.entry("offset", "Vertical distance from the terrain surface in blocks."),
Map.entry("output", "Path of the output file that could not be written."),
Map.entry("overall", "Average pregeneration chunks per second over the whole operation."),
Map.entry("ownerName", "Name of the player who owns the Studio session."),
Map.entry("pack", "Name or identifier of the Iris pack involved in the operation."),
Map.entry("pack2", "Pack identifier repeated in a suggested command or download source."),
Map.entry("packDimension", "Dimension definition selected from the pack."),
Map.entry("packLogical", "Logical height requested by the pack's dimension definition."),
Map.entry("packMax", "Upper height boundary requested by the pack."),
Map.entry("packMin", "Lower height boundary requested by the pack."),
Map.entry("packName", "Display name of the pack being validated."),
Map.entry("parameter", "Command parameter that is missing or cannot be parsed."),
Map.entry("parent", "Display name of the parent menu or command route."),
Map.entry("path", "Path of the file being saved, opened or reported."),
Map.entry("percent", "Progress percentage; keep the message's percent sign outside the variable."),
Map.entry("permission", "Permission node required for the requested action."),
Map.entry("personal", "Player's saved personal language identifier."),
Map.entry("phase", "Name of the current loading or download phase."),
Map.entry("pinned", "Number of leaves pinned while repairing a tree object."),
Map.entry("player", "Name of the player being followed or targeted."),
Map.entry("plugin", "Plugin display name, Iris in Iris's shared language and debug messages."),
Map.entry("position", "Selection corner number being set."),
Map.entry("preset", "Registered world-generation preset used for the main world."),
Map.entry("primary", "Identifier of the configured primary world."),
Map.entry("processed", "Number of items processed, including successful, skipped and failed items."),
Map.entry("properties", "Formatted list of the inspected block's properties."),
Map.entry("quarantinePath", "Folder containing quarantined cleanup files."),
Map.entry("queued", "Number of workers or work items waiting to run."),
Map.entry("radius", "Pregeneration radius repeated in the suggested start command."),
Map.entry("rarity", "Configured rarity value of the named region or placement."),
Map.entry("rate", "Formatted transfer or pregeneration rate; the surrounding message adds the per-second suffix."),
Map.entry("reach", "Configured branch-search reach used when repairing tree objects."),
Map.entry("ready", "Number of exact Atlas pages currently available."),
Map.entry("reason", "Explanation of why the requested action could not finish."),
Map.entry("region", "Name or identifier of the region being inspected."),
Map.entry("remaining", "Number of additional block states omitted from the displayed list."),
Map.entry("repository", "Repository or downloaded archive being unpacked."),
Map.entry("reverted", "Number of object pastes reverted."),
Map.entry("rotation", "Rotation applied to the placed object."),
Map.entry("rx", "Chunk X coordinate being hashed."),
Map.entry("rz", "Chunk Z coordinate being hashed."),
Map.entry("s", "Name of the new Studio project."),
Map.entry("safeName", "Sanitized filename used for the imported object."),
Map.entry("scale", "World blocks represented by one preview pixel."),
Map.entry("scope", "Object or group of objects opened in Object Studio."),
Map.entry("searchRadius", "Structure-verification search radius in chunks."),
Map.entry("seconds", "Seconds component of a duration or time since the last update."),
Map.entry("section", "Help-section name requested by the user."),
Map.entry("seed", "Seed used for the world, Studio session, generator or reference comparison."),
Map.entry("seedRaw", "Seed exactly as entered before validation."),
Map.entry("sixtySecond", "Pregeneration chunks per second over the 60-second window."),
Map.entry("size", "Number of files, warnings, entries or items named in the message."),
Map.entry("skipped", "Number of items skipped by the operation."),
Map.entry("slope", "Terrain slope at the inspected position."),
Map.entry("solidBlocks", "Number of solid blocks counted by generation hashing."),
Map.entry("source", "Pack, download source or input associated with the progress display."),
Map.entry("speed", "Formatted pregeneration or processing rate per second."),
Map.entry("stage", "Name of the current progress stage."),
Map.entry("state", "Current state or property value named by the message."),
Map.entry("status", "Current preview status text."),
Map.entry("structure", "Name or identifier of the structure being located."),
Map.entry("structureKey", "Requested structure's registry or pack identifier."),
Map.entry("summary", "Formatted results of the completed operation."),
Map.entry("suppressed", "Number of native structures replaced by Iris placements."),
Map.entry("surface", "Surface Y coordinate used in the suggested teleport command."),
Map.entry("surfaceY", "Y coordinate of the terrain surface at the inspected position."),
Map.entry("tag", "Formatted validation status label placed before the pack name."),
Map.entry("target", "Language recipient or object-selection target named in the message."),
Map.entry("targetX", "X coordinate of the located or requested destination."),
Map.entry("targetZ", "Z coordinate of the located or requested destination."),
Map.entry("template", "Template selected when creating a project."),
Map.entry("tenSecond", "Pregeneration chunks per second over the 10-second window."),
Map.entry("thirtySecond", "Pregeneration chunks per second over the 30-second window."),
Map.entry("threads", "Number of worker threads used for generation hashing."),
Map.entry("tileNote", "Optional note about saved or placed block-entity data."),
Map.entry("title", "Title of the operation whose result is being displayed."),
Map.entry("to", "New debug setting: true for enabled, false for disabled."),
Map.entry("token", "Reach argument that could not be parsed."),
Map.entry("total", "Total number of work items, chunks, dimensions or entries named in the message."),
Map.entry("totalObjects", "Number of objects opened in Object Studio."),
Map.entry("transferred", "Formatted amount of data transferred so far."),
Map.entry("type", "Expected parameter type, resource type or object category named in the message."),
Map.entry("typeKey", "Registered Minecraft dimension-type identifier, or inline."),
Map.entry("unreachableBiomes", "Number of native structures excluded by the pack's biome choices."),
Map.entry("unsupported", "Number of native structures reported as unsupported by this platform."),
Map.entry("updates", "Block updates per second in the developer display."),
Map.entry("url", "Web address to open or copy."),
Map.entry("usage", "Command syntax, or memory-usage text in the memory display."),
Map.entry("used", "Formatted used-memory amount in the memory display."),
Map.entry("value", "Message-specific value: for example a default setting, name, coordinate, count or error. Read the surrounding text."),
Map.entry("value2", "Second message-specific value: for example total chunks, target Y, a pack name or an error. Read the surrounding text."),
Map.entry("value3", "Third message-specific value: for example a percentage, target Z, Minecraft version or elapsed time."),
Map.entry("value4", "Fourth message-specific value: for example block writes, a selection's Z coordinate or an optional status suffix."),
Map.entry("value5", "Fifth message-specific value: non-air block writes in object placement, or chunk Z in the position template."),
Map.entry("variables", "List of placeholders available for the message being edited."),
Map.entry("velocity", "Current movement velocity of the inspected entity."),
Map.entry("version", "Plugin or Minecraft version identified by the surrounding message."),
Map.entry("volume", "Number of blocks contained in the object selection."),
Map.entry("w", "Object width in blocks."),
Map.entry("wantVariants", "Requested variants per imported vanilla tree or object feature."),
Map.entry("warnings", "Number of non-blocking validation warnings."),
Map.entry("width", "Width of the displayed world area in blocks."),
Map.entry("wood", "Number of wood blocks added while repairing a tree object."),
Map.entry("world", "Name of the world targeted by the command or progress display."),
Map.entry("written", "Number of imported variants or object files successfully written."),
Map.entry("x", "X coordinate in the block, chunk, region or preview context named by the message."),
Map.entry("y", "Vertical world or block coordinate."),
Map.entry("z", "Z coordinate in the block, chunk, region or preview context named by the message."),
Map.entry("zoom", "Current zoom level of the preview.")
);
private static final List<String> PREFIX_HELP = List.of(
"Iris has no language-wide prefix setting. Prefix text in one message changes only that message.",
"Plugin log prefixes and menu themes are supplied separately.",
"Example under [iris.bukkit.commandiris]:",
"set_debug = \"&6[Worlds]&r &aSet debug to: {to}\"",
"/iris debug then shows the Worlds prefix; {to} is true when enabled and false when disabled.",
"Use only the variables already present in each message. {PC}, {SC} and {TC} are not Iris theme variables.",
"For value, value2 and other numbered variables, the surrounding message determines their meaning."
);
private static final List<String> FORMATTING_HELP = List.of(
"Classic colors: &0 black, &1 dark blue, &2 dark green, &3 dark aqua.",
"&4 dark red, &5 dark purple, &6 gold, &7 gray.",
"&8 dark gray, &9 blue, &a green, &b aqua.",
"&c red, &d light purple, &e yellow, &f white.",
"Styles: &k obfuscated, &l bold, &m strikethrough, &n underline, &o italic, &r reset.",
"Example: &6[Worlds]&r &aReady uses a gold prefix, resets its style, then uses green text.",
"The language parser accepts &#RRGGBB, &xRRGGBB, &x&R&R&G&G&B&B and [RRGGBB].",
"Bukkit themes and downstream formatting can alter colors, including RGB colors.",
"Modded commands, help and client screens use plain translated text with their own styles.",
"In double-quoted TOML strings: \\n adds a newline, \\\" inserts a quote, \\\\ inserts a backslash.",
"A backslash before & or [ only escapes the language parser; later display formatting may interpret it again."
);
private IrisLanguageGuide() {
}
static Map<String, String> variableDescriptions() {
return VARIABLE_DESCRIPTIONS;
}
static String header(String locale) throws IOException {
try (InputStream input = IrisLanguageGuide.class.getResourceAsStream("/iris-language-guides/" + locale + ".txt")) {
if (input != null) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8).stripTrailing() + "\n";
}
}
return englishHeader(locale);
}
static String englishHeader(String locale) {
List<String> lines = new ArrayList<>();
lines.add("=== Iris language guide ===");
for (String line : LanguageFileHeader.render(new LanguageFileHeader.Options(
"Iris", locale, PREFIX_HELP, FORMATTING_HELP, VARIABLE_DESCRIPTIONS))) {
lines.add(line.equals("plugins/Iris/languages/" + locale + ".toml")
? "languages/" + locale + ".toml" : line);
}
lines.add("=== End Iris language guide ===");
return TomlLanguageWriter.render(Map.of(), lines);
}
static void refresh(File file, String locale) throws IOException {
if (!Files.isRegularFile(file.toPath(), LinkOption.NOFOLLOW_LINKS)
|| Files.size(file.toPath()) > 2L * 1024L * 1024L) {
return;
}
String original = Files.readString(file.toPath(), StandardCharsets.UTF_8);
String updated = replaceHeader(original, header(locale), locale);
if (original.equals(updated)) {
return;
}
LanguageFileEditor.update(file.toPath(), current -> {
if (!current.equals(original)) {
throw new IOException("Language file changed while refreshing its guide: " + file);
}
return new LanguageFileEditor.Prepared<>(updated, null);
});
}
static String replaceHeader(String content, String header, String locale) {
int offset = 0;
int start = -1;
int end = -1;
boolean marked = false;
while (offset < content.length()) {
int newline = content.indexOf('\n', offset);
int next = newline < 0 ? content.length() : newline + 1;
String line = content.substring(offset, newline < 0 ? content.length() : newline).strip();
if (!line.isEmpty() && !line.startsWith("#")) {
break;
}
if (line.equals(START)) {
start = offset;
marked = true;
} else if (marked && line.equals(END)) {
return content.substring(0, start) + header + content.substring(next);
} else if (!marked && (line.equals("# Iris - " + locale) || line.equals("# Iris — " + locale))) {
start = offset;
} else if (!marked && start >= 0 && UNDESCRIBED_VARIABLES.matcher(line).matches()) {
end = next;
}
offset = next;
}
if (!marked && start >= 0 && end > start
&& matchesUnmarkedGuide(content.substring(start, end), locale)) {
return content.substring(0, start) + header + content.substring(end);
}
return content;
}
private static boolean matchesUnmarkedGuide(String content, String locale) {
String expected = UNMARKED_GUIDE_FINGERPRINTS.get(locale);
if (expected == null) {
return false;
}
StringBuilder prose = new StringBuilder();
for (String line : content.lines().toList()) {
String normalized = line.stripTrailing();
if (normalized.isBlank() || normalized.equals("#")
|| UNDESCRIBED_VARIABLES.matcher(normalized).matches()) {
continue;
}
prose.append(normalized).append('\n');
}
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(prose.toString().getBytes(StandardCharsets.UTF_8));
return expected.equals(HexFormat.of().formatHex(digest));
} catch (NoSuchAlgorithmException failure) {
throw new IllegalStateException("SHA-256 is unavailable", failure);
}
}
}
@@ -1,6 +1,8 @@
package art.arcane.iris.core.localization;
import art.arcane.volmlib.util.diagnostics.BukkitDebugMessages;
import art.arcane.volmlib.util.director.DirectorMessages;
import art.arcane.volmlib.util.localization.BukkitLanguageMessages;
import art.arcane.volmlib.util.localization.MessageCatalog;
import art.arcane.volmlib.util.localization.MessageKey;
import art.arcane.volmlib.util.localization.TextKey;
@@ -105,6 +107,8 @@ public final class IrisMessages {
private static MessageCatalog createCatalog() {
MessageCatalog.Builder builder = MessageCatalog.builder(VolmitLocales.ENGLISH);
builder.addAll(DirectorMessages.keys());
builder.addAll(BukkitLanguageMessages.keys());
builder.addAll(BukkitDebugMessages.keys());
builder.addAll(RUNTIME_KEYS);
builder.addAll(BukkitCommandMessages.keys());
builder.addAll(BukkitCommandMessagesExtended.keys());
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -46,7 +46,7 @@ public class SettingsHotloadWatchTest {
previousSettings = IrisSettings.settings;
dataFolder = temporaryFolder.newFolder("iris-hotload");
settingsFile = new File(dataFolder, "iris.json");
overrideFolder = new File(dataFolder, "languages/overrides");
overrideFolder = new File(dataFolder, "languages");
Files.createDirectories(overrideFolder.toPath());
String settings = settings("en_US");
Files.writeString(settingsFile.toPath(), settings, StandardCharsets.UTF_8);
@@ -68,15 +68,15 @@ public class SettingsHotloadWatchTest {
}
@Test
public void activeLocaleSnapshotAppliesAndInvalidSnapshotRetainsLastGood() {
public void activeLocaleSnapshotAppliesAndInvalidSnapshotUsesEnglish() {
File override = override("en_US");
String valid = locale("en_US", "Active {permission}");
assertTrue(watch.applySnapshot(present(override, valid)));
assertEquals("Active " + PERMISSION, permissionMessage());
assertFalse(watch.applySnapshot(present(override, "{ invalid")));
assertEquals("Active " + PERMISSION, permissionMessage());
assertTrue(watch.applySnapshot(present(override, "{ invalid")));
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@Test
@@ -120,13 +120,13 @@ public class SettingsHotloadWatchTest {
}
@Test
public void invalidLocaleBlocksItsSettingsSwitchAndRetainsThePreviousRuntime() throws Exception {
public void invalidLocaleAllowsItsSettingsSwitchWithEnglishFallback() throws Exception {
Files.writeString(override("de_DE").toPath(), "{ invalid", StandardCharsets.UTF_8);
assertFalse(watch.applySnapshot(present(settingsFile, settings("de_DE"))));
assertTrue(watch.applySnapshot(present(settingsFile, settings("de_DE"))));
assertEquals("en_US", IrisSettings.get().getGeneral().getLanguage());
assertEquals("en_US", IrisLanguage.activeLocale());
assertEquals("de_DE", IrisSettings.get().getGeneral().getLanguage());
assertEquals("de_DE", IrisLanguage.activeLocale());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@@ -226,7 +226,7 @@ public class SettingsHotloadWatchTest {
}
@Test(timeout = 8_000L)
public void oversizedActiveOverrideReportsOnceAndRetainsLastGoodCatalog() throws Exception {
public void oversizedActiveLanguageReportsOnceAndUsesEnglish() throws Exception {
File override = override("en_US");
assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}"))));
byte[] oversized = new byte[2 * 1024 * 1024 + 1];
@@ -238,11 +238,11 @@ public class SettingsHotloadWatchTest {
diagnostic,
"Failed to read watched Iris file " + override.getAbsolutePath()
));
assertEquals("Active " + PERMISSION, permissionMessage());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
@Test(timeout = 8_000L)
public void malformedUtf8FailureIsDeduplicatedAndRetainsLastGoodCatalog() throws Exception {
public void malformedUtf8FailureIsDeduplicatedAndUsesEnglish() throws Exception {
File override = override("en_US");
assertTrue(watch.applySnapshot(present(override, locale("en_US", "Active {permission}"))));
Files.write(override.toPath(), new byte[]{(byte) 0xC3, 0x28});
@@ -253,7 +253,7 @@ public class SettingsHotloadWatchTest {
diagnostic,
"Failed to read watched Iris file " + override.getAbsolutePath()
));
assertEquals("Active " + PERMISSION, permissionMessage());
assertEquals("You lack the permission '" + PERMISSION + "'", permissionMessage());
}
private void awaitPermissionMessage(String expected) {
@@ -326,15 +326,15 @@ public class SettingsHotloadWatchTest {
}
private ConfigHotloadEngine.StableContentSnapshot present(File file, String content) {
return new ConfigHotloadEngine.StableContentSnapshot(file, "present", content.trim());
return new ConfigHotloadEngine.StableContentSnapshot(file, "present", content.trim(), 0L);
}
private ConfigHotloadEngine.StableContentSnapshot missing(File file) {
return new ConfigHotloadEngine.StableContentSnapshot(file, "missing", null);
return new ConfigHotloadEngine.StableContentSnapshot(file, "missing", null, 0L);
}
private File override(String locale) {
return new File(overrideFolder, locale + ".json").getAbsoluteFile();
return new File(overrideFolder, locale + ".toml").getAbsoluteFile();
}
private String permissionMessage() {
@@ -349,7 +349,6 @@ public class SettingsHotloadWatchTest {
}
private String locale(String locale, String permissionMessage) {
return "{\"locale\":\"" + locale + "\",\"messages\":{"
+ "\"iris.command.permission_denied\":\"" + permissionMessage + "\"}}";
return "[iris.command]\npermission_denied = \"" + permissionMessage + "\"\n";
}
}
@@ -4,8 +4,7 @@ import art.arcane.iris.testsupport.ProjectPaths;
import art.arcane.volmlib.util.localization.LocaleOverlay;
import art.arcane.volmlib.util.localization.LocalizationValidator;
import art.arcane.volmlib.util.localization.RemoteLanguageCatalog;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import art.arcane.volmlib.util.localization.TextValue;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.Rule;
@@ -30,34 +29,26 @@ import static org.junit.Assert.assertTrue;
public class IrisDownloadValidationTest {
private static final String LOCALE = "de_DE";
private static final String MISSING_KEY = "iris.director.commanddeveloper.director.stage_world_generation_update";
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void incompleteCacheDownloadsCompleteCatalogAndReusesIt() throws Exception {
public void missingLanguageDownloadsOnceAndPreservesLaterEdits() throws Exception {
String complete = completeCatalog();
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(complete, requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path cache = catalog.cacheFile(LOCALE);
Files.createDirectories(cache.getParent());
Files.writeString(cache, incompleteCatalog(complete));
RemoteLanguageCatalog.CacheResult initial = catalog.read(LOCALE, IrisLanguage::validateDownload);
assertEquals(RemoteLanguageCatalog.CacheState.INVALID, initial.state());
assertTrue(initial.failure().getMessage().contains(MISSING_KEY));
assertEquals(0, requests.get());
assertEquals(complete, catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertEquals(complete, Files.readString(cache));
assertEquals(RemoteLanguageCatalog.CacheState.VALID,
catalog.read(LOCALE, IrisLanguage::validateDownload).state());
Path language = temporaryFolder.newFolder().toPath().resolve("languages/de_DE.toml");
assertEquals(complete, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(complete, Files.readString(language));
assertEquals(1, requests.get());
assertEquals(complete, catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
String edited = "\"iris.command.unknown\" = \"Eigener Befehl\"\n";
Files.writeString(language, edited);
assertEquals(edited, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(edited, Files.readString(language));
assertEquals(1, requests.get());
} finally {
server.stop(0);
@@ -65,36 +56,34 @@ public class IrisDownloadValidationTest {
}
@Test
public void incompleteUpstreamDoesNotReplaceAnInvalidCache() throws Exception {
String incomplete = incompleteCatalog(completeCatalog());
public void existingUnreadableLanguageIsNeverReplacedByADownload() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(incomplete, requests);
HttpServer server = server(completeCatalog(), requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path cache = catalog.cacheFile(LOCALE);
Files.createDirectories(cache.getParent());
String cached = "{\"locale\":\"de_DE\",\"messages\":{}}";
Files.writeString(cache, cached);
Path language = temporaryFolder.newFile("de_DE.toml").toPath();
String unreadable = "[unterminated";
Files.writeString(language, unreadable);
IllegalArgumentException failure = assertThrows(IllegalArgumentException.class,
() -> catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertTrue(failure.getMessage().contains(MISSING_KEY));
assertEquals(cached, Files.readString(cache));
assertEquals(1, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void incompleteUpstreamDoesNotCreateACacheFile() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(incompleteCatalog(completeCatalog()), requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
assertThrows(IllegalArgumentException.class,
() -> catalog.readOrDownload(LOCALE, IrisLanguage::validateDownload));
assertFalse(Files.exists(catalog.cacheFile(LOCALE)));
() -> catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(unreadable, Files.readString(language));
assertEquals(0, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void malformedDownloadDoesNotCreateALanguageFile() throws Exception {
AtomicInteger requests = new AtomicInteger();
HttpServer server = server("[unterminated", requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path language = temporaryFolder.newFolder().toPath().resolve("de_DE.toml");
assertThrows(IllegalArgumentException.class,
() -> catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertFalse(Files.exists(language));
assertEquals(1, requests.get());
} finally {
server.stop(0);
@@ -102,9 +91,33 @@ public class IrisDownloadValidationTest {
}
@Test
public void partialOperatorOverridesRemainValid() {
LocaleOverlay overlay = IrisLanguage.parseOverlay("override", LOCALE, """
{"locale":"de_DE","messages":{"iris.command.unknown":"Unbekannter Iris-Befehl"}}
public void partialDownloadsInstallTheirValidMessages() throws Exception {
String partial = """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.command.permission_denied" = "Missing placeholder"
"future.message" = "Future value"
""";
AtomicInteger requests = new AtomicInteger();
HttpServer server = server(partial, requests);
try (URLClassLoader resources = resources();
RemoteLanguageCatalog catalog = catalog(server, resources)) {
Path language = temporaryFolder.newFolder().toPath().resolve("de_DE.toml");
assertEquals(partial, catalog.readOrInstall(LOCALE, language, IrisLanguage::validateDownload));
assertEquals(partial, Files.readString(language));
LocaleOverlay overlay = IrisLanguage.parseOverlay(language.toString(), LOCALE, partial);
assertEquals(1, overlay.values().size());
assertEquals("Unbekannter Iris-Befehl", ((TextValue)
overlay.value(IrisMessages.COMMAND_UNKNOWN.id())).template());
assertEquals(1, requests.get());
} finally {
server.stop(0);
}
}
@Test
public void partialLanguageFilesRemainValid() {
LocaleOverlay overlay = IrisLanguage.parseOverlay("language", LOCALE, """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertEquals(1, overlay.values().size());
@@ -112,19 +125,13 @@ public class IrisDownloadValidationTest {
}
private String completeCatalog() throws IOException {
return Files.readString(ProjectPaths.moduleFile("src/main/resources/languages").resolve(LOCALE + ".json"));
}
private String incompleteCatalog(String complete) {
JsonObject document = JsonParser.parseString(complete).getAsJsonObject();
assertTrue(document.getAsJsonObject("messages").remove(MISSING_KEY) != null);
return document.toString();
return Files.readString(ProjectPaths.moduleFile("src/main/resources/languages").resolve(LOCALE + ".toml"));
}
private HttpServer server(String content, AtomicInteger requests) throws IOException {
byte[] response = content.getBytes(StandardCharsets.UTF_8);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/master/languages/de_DE.json", exchange -> respond(exchange, response, requests));
server.createContext("/master/languages/de_DE.toml", exchange -> respond(exchange, response, requests));
server.start();
return server;
}
@@ -150,9 +157,8 @@ public class IrisDownloadValidationTest {
"Iris",
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"),
"languages",
".json",
".toml",
"source.properties",
temporaryFolder.newFolder().toPath(),
resources
));
}
@@ -1,6 +1,7 @@
package art.arcane.iris.core.localization;
import art.arcane.iris.testsupport.ProjectPaths;
import art.arcane.volmlib.util.config.TomlCodec;
import art.arcane.volmlib.util.localization.LocalizationCandidate;
import art.arcane.volmlib.util.localization.LocalizationSnapshot;
import art.arcane.volmlib.util.localization.PluginLanguageEditor;
@@ -8,7 +9,9 @@ import art.arcane.volmlib.util.localization.PluginLanguageService;
import art.arcane.volmlib.util.localization.PluralSelector;
import art.arcane.volmlib.util.localization.RemoteLanguageCatalog;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.TomlLanguageEditor;
import art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonElement;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.After;
@@ -32,7 +35,6 @@ import java.util.logging.Logger;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
@@ -48,9 +50,9 @@ public class IrisLanguageEditorTest {
public void prepareEditor() throws Exception {
directory = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(directory, "en_US"));
Path cache = IrisLanguage.remote(directory).cacheFile("fr_FR");
Files.createDirectories(cache.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.json"), cache);
Path language = directory.toPath().resolve("languages/fr_FR.toml");
Files.createDirectories(language.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.toml"), language);
PluginLanguageEditor.Options options = IrisLanguage.editorOptions();
LocalizationSnapshot english = LocalizationSnapshot.create(
LocalizationCandidate.english(IrisLanguage.catalog(), PluralSelector.oneOther()));
@@ -73,20 +75,22 @@ public class IrisLanguageEditorTest {
@Test
public void savesOneLocaleAndRefreshesItsPersonalSnapshotWithoutSelectingIt() throws Exception {
UUID player = UUID.randomUUID();
Path english = directory.toPath().resolve("languages/en_US.toml");
byte[] englishBefore = Files.readAllBytes(english);
languages.selectPlayer(player, "fr_FR").get(5, TimeUnit.SECONDS);
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Langue \"{locale}\" rechargee.\nSuite.");
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/overrides/fr_FR.json");
Path file = directory.toPath().resolve("languages/fr_FR.toml");
assertTrue(Files.isRegularFile(file));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals(value, languages.snapshot(player).value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals("fr_FR", languages.playerLocale(player).orElseThrow());
assertEquals("en_US", languages.defaultLocale());
assertEquals("en_US", IrisLanguage.activeLocale());
assertFalse(Files.exists(directory.toPath().resolve("languages/overrides/en_US.json")));
assertArrayEquals(englishBefore, Files.readAllBytes(english));
}
@Test
@@ -95,7 +99,7 @@ public class IrisLanguageEditorTest {
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), new TextValue("Langue {locale}")))
.get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/overrides/fr_FR.json");
Path file = directory.toPath().resolve("languages/fr_FR.toml");
byte[] before = Files.readAllBytes(file);
PluginLanguageEditor.Document saved = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
@@ -106,8 +110,8 @@ public class IrisLanguageEditorTest {
}
@Test
public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception {
byte[] complete = Files.readAllBytes(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.json"));
public void missingLocaleDownloadsAndCanBeEditedWithoutBeingSelected() throws Exception {
byte[] complete = Files.readAllBytes(ProjectPaths.moduleFile("src/main/resources/languages/fr_FR.toml"));
AtomicInteger requests = new AtomicInteger();
Field remoteField = IrisLanguage.class.getDeclaredField("remote");
remoteField.setAccessible(true);
@@ -117,19 +121,19 @@ public class IrisLanguageEditorTest {
"Iris",
URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/"),
"languages",
".json",
".toml",
"iris-language-source.properties",
directory.toPath().resolve("languages/downloaded"),
IrisLanguage.class.getClassLoader()
))) {
server.createContext(catalog.sourceUri("fr_FR").getPath(),
exchange -> respond(exchange, complete, requests));
server.start();
remoteField.set(null, catalog);
Path cache = catalog.cacheFile("fr_FR");
Files.writeString(cache, "{\"locale\":\"fr_FR\",\"messages\":{}}");
Path language = directory.toPath().resolve("languages/fr_FR.toml");
Files.delete(language);
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
assertArrayEquals(complete, Files.readAllBytes(language));
TextValue value = new TextValue("Langue {locale}");
PluginLanguageEditor.Document saved = editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
@@ -137,7 +141,7 @@ public class IrisLanguageEditorTest {
assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals("en_US", languages.defaultLocale());
assertEquals("en_US", IrisLanguage.activeLocale());
assertArrayEquals(complete, Files.readAllBytes(cache));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals(1, requests.get());
} finally {
remoteField.set(null, previous);
@@ -145,6 +149,55 @@ public class IrisLanguageEditorTest {
}
}
@Test
public void savingOneMessagePreservesUnknownValuesAndLeadingComments() throws Exception {
Path file = directory.toPath().resolve("languages/fr_FR.toml");
String header = "# Operator customization\n";
String extension = "\n[future]\nvalue = \"Preserve this\"\nenabled = true\norder = [1, 2]\n";
String source = header + Files.readString(file) + extension;
Files.writeString(file, source);
JsonElement unknown = TomlCodec.toJsonElement(source).getAsJsonObject().get("future");
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Langue {locale}");
editor.save(new PluginLanguageEditor.Edit("fr_FR", IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS), value)).get(5, TimeUnit.SECONDS);
String saved = Files.readString(file);
assertTrue(saved.startsWith(header));
assertEquals(unknown, TomlCodec.toJsonElement(saved).getAsJsonObject().get("future"));
assertEquals(value, IrisLanguage.editorOptions().loader().load("fr_FR").value(IrisMessages.COMMAND_RELOAD_SUCCESS));
}
@Test
public void staleEditorSaveDoesNotReplaceANewerLocalEdit() throws Exception {
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
Path file = directory.toPath().resolve("languages/fr_FR.toml");
String external = TomlLanguageEditor.upsert(Files.readString(file), IrisMessages.COMMAND_RELOAD_SUCCESS.id(),
new TextValue("Modification externe {locale}")).content();
Files.writeString(file, external);
assertThrows(ExecutionException.class, () -> editor.save(new PluginLanguageEditor.Edit("fr_FR",
IrisMessages.COMMAND_RELOAD_SUCCESS.id(), original.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS),
new TextValue("Modification ancienne {locale}"))).get(5, TimeUnit.SECONDS));
assertEquals(external, Files.readString(file));
}
@Test
public void editingDefaultEnglishRefreshesServerMessagesWithoutSelectingAnotherLocale() throws Exception {
PluginLanguageEditor.Document original = editor.load("en_US").get(5, TimeUnit.SECONDS);
TextValue value = new TextValue("Custom Iris command");
editor.save(new PluginLanguageEditor.Edit("en_US", IrisMessages.COMMAND_UNKNOWN.id(),
original.snapshot().value(IrisMessages.COMMAND_UNKNOWN), value)).get(5, TimeUnit.SECONDS);
assertEquals("Custom Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals("en_US", IrisLanguage.activeLocale());
assertEquals("en_US", languages.defaultLocale());
assertEquals(value, IrisLanguage.editorOptions().loader().load("en_US").value(IrisMessages.COMMAND_UNKNOWN));
}
private static void respond(HttpExchange exchange, byte[] response, AtomicInteger requests) throws IOException {
requests.incrementAndGet();
try {
@@ -0,0 +1,123 @@
package art.arcane.iris.core.localization;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class IrisLanguageGuideTest {
private static final String ENGLISH_GUIDE = """
# Iris — en_US
#
# === File editing ===
# plugins/Iris/languages/en_US.toml
# Edit this file directly. Local changes are preserved; missing or invalid entries use built-in English.
# /iris language server edit en_US
#
# === Prefix ===
# Prefixes belong to individual messages or the platform display.
#
# === Formatting ===
# Colors and styles: &0-&f, &k-&r.
# RGB colors: &#RRGGBB, &xRRGGBB, &x&R&R&G&G&B&B, [RRGGBB].
# Put a backslash before & or [ to display it literally.
# Keep the placeholders already present in each message.
#
# === Variables ===
# Keep variable names unchanged and use only the variables belonging to each message.
# {permission}
# {world}
""";
private static final String GERMAN_GUIDE = """
# Iris - de_DE
#
# === Datei bearbeiten ===
# languages/de_DE.toml
# Diese Datei kann direkt bearbeitet werden. Lokale Änderungen bleiben erhalten; fehlende oder ungültige Einträge verwenden das eingebaute Englisch.
# /iris language server edit de_DE
#
# === Präfix ===
# Präfixe stehen in einzelnen Nachrichten oder werden von der jeweiligen Server- oder Client-Oberfläche ergänzt.
#
# === Formatierung ===
# Farben: &0-&f, &k-&r, &#RRGGBB, &xRRGGBB, &x&R&R&G&G&B&B und [RRGGBB].
# Ein vorangestellter Rückwärtsschrägstrich zeigt & oder [ wörtlich an.
#
# === Variablen ===
# Variablennamen unverändert lassen und nur die Variablen der jeweiligen Nachricht verwenden.
# {permission} {world}
""";
@Rule
public TemporaryFolder temporary = new TemporaryFolder();
@Test
public void refreshesKnownEnglishProseAcrossLineEndingsAndCatalogSizes() throws Exception {
String note = "# Local operator note\r\n";
String body = "\r\n# Keep this explanation\r\n[iris.command]\r\nunknown = 'Custom answer'\r\n"
+ "[extra]\r\nenabled = true\r\n";
File file = temporary.newFile("en_US.toml");
Files.writeString(file.toPath(), note + ENGLISH_GUIDE.replace("\n", "\r\n") + body);
IrisLanguageGuide.refresh(file, "en_US");
assertArrayEquals((note + IrisLanguageGuide.englishHeader("en_US") + body).getBytes(StandardCharsets.UTF_8),
Files.readAllBytes(file.toPath()));
}
@Test
public void refreshesKnownGermanProsePreservingMessageBytes() throws Exception {
String body = "\n# Eigene Erklärung\n[iris.command]\nunknown = 'Eigene Nachricht'\n";
File file = temporary.newFile("de_DE.toml");
Files.writeString(file.toPath(), GERMAN_GUIDE + body);
IrisLanguageGuide.refresh(file, "de_DE");
assertArrayEquals((IrisLanguageGuide.header("de_DE") + body).getBytes(StandardCharsets.UTF_8),
Files.readAllBytes(file.toPath()));
}
@Test
public void leavesCustomCommentsInsideAnUnmarkedGuideUntouched() throws Exception {
String custom = ENGLISH_GUIDE.replace("# === Formatting ===", "# Operator's custom explanation\n# === Formatting ===")
+ "\n[iris.command]\nunknown = 'Custom answer'\n";
File file = temporary.newFile("en_US.toml");
Files.writeString(file.toPath(), custom);
FileTime originalTime = FileTime.fromMillis(123_456L);
Files.setLastModifiedTime(file.toPath(), originalTime);
IrisLanguageGuide.refresh(file, "en_US");
assertArrayEquals(custom.getBytes(StandardCharsets.UTF_8), Files.readAllBytes(file.toPath()));
assertEquals(originalTime, Files.getLastModifiedTime(file.toPath()));
}
@Test
public void leavesChangedGuideProseAndUnknownUnmarkedGuidesUntouched() {
String changed = GERMAN_GUIDE.replace("# languages/de_DE.toml", "# My chosen file location")
+ "\n[iris.command]\nunknown = 'Custom answer'\n";
String unknown = "# Iris - custom\n# Custom instructions\n# {world}\n[iris.command]\nunknown = 'Custom answer'\n";
assertEquals(changed, IrisLanguageGuide.replaceHeader(changed, IrisLanguageGuide.englishHeader("de_DE"), "de_DE"));
assertEquals(unknown, IrisLanguageGuide.replaceHeader(unknown, IrisLanguageGuide.englishHeader("custom"), "custom"));
}
@Test
public void englishLocationIsRelativeToThePlatformDataFolder() {
String header = IrisLanguageGuide.englishHeader("en_US");
assertTrue(header.contains("# languages/en_US.toml\n"));
assertFalse(header.contains("# plugins/Iris/languages/en_US.toml"));
}
}
@@ -5,6 +5,7 @@ import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.attribute.FileTime;
@@ -13,16 +14,17 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisLanguageHotloadSnapshotTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
@Test
public void capturesMissingOverrideAsStableTombstone() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
public void capturesMissingLanguageAsStableTombstone() throws Exception {
File language = new File(temporaryFolder.getRoot(), "languages/en_US.toml");
LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(override, "en_US");
LocaleHotloadSnapshot snapshot = IrisLanguage.captureHotloadSnapshot(language, "en_US");
assertTrue(snapshot.missing());
assertEquals("missing", snapshot.sha256());
@@ -30,18 +32,18 @@ public class IrisLanguageHotloadSnapshotTest {
@Test
public void detectsSameMetadataContentReplacementBySha256() throws Exception {
File override = new File(temporaryFolder.getRoot(), "languages/overrides/en_US.json");
Files.createDirectories(override.toPath().getParent());
String firstContent = "{\"messages\":{\"a\":\"1\"}}";
String secondContent = "{\"messages\":{\"a\":\"2\"}}";
Files.writeString(override.toPath(), firstContent, StandardCharsets.UTF_8);
File language = new File(temporaryFolder.getRoot(), "languages/en_US.toml");
Files.createDirectories(language.toPath().getParent());
String firstContent = "a = \"1\"\n";
String secondContent = "a = \"2\"\n";
Files.writeString(language.toPath(), firstContent, StandardCharsets.UTF_8);
FileTime fixedTime = FileTime.fromMillis(10_000L);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(override, "en_US");
Files.setLastModifiedTime(language.toPath(), fixedTime);
LocaleHotloadSnapshot first = IrisLanguage.captureHotloadSnapshot(language, "en_US");
Files.writeString(override.toPath(), secondContent, StandardCharsets.UTF_8);
Files.setLastModifiedTime(override.toPath(), fixedTime);
LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(override, "en_US");
Files.writeString(language.toPath(), secondContent, StandardCharsets.UTF_8);
Files.setLastModifiedTime(language.toPath(), fixedTime);
LocaleHotloadSnapshot second = IrisLanguage.captureHotloadSnapshot(language, "en_US");
assertFalse(first.missing());
assertFalse(second.missing());
@@ -52,4 +54,27 @@ public class IrisLanguageHotloadSnapshotTest {
assertNotEquals(first.sha256(), second.sha256());
assertNotEquals(first, second);
}
@Test
public void snapshotsRejectFilesLargerThanTwoMebibytes() throws Exception {
File language = temporaryFolder.newFile("en_US.toml");
Files.write(language.toPath(), new byte[2 * 1024 * 1024 + 1]);
assertThrows(IllegalArgumentException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
@Test
public void snapshotsRejectInvalidUtf8() throws Exception {
File language = temporaryFolder.newFile("en_US.toml");
Files.write(language.toPath(), new byte[]{(byte) 0xC3, (byte) 0x28});
assertThrows(IOException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
@Test
public void snapshotsRejectDirectoriesAtTheLanguagePath() throws Exception {
File language = temporaryFolder.newFolder("en_US.toml");
assertThrows(IllegalArgumentException.class, () -> IrisLanguage.captureHotloadSnapshot(language, "en_US"));
}
}
@@ -12,7 +12,6 @@ import art.arcane.volmlib.util.localization.MessageValue;
import art.arcane.volmlib.util.localization.PluralValue;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonArray;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
@@ -25,6 +24,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -116,50 +116,52 @@ public class IrisLanguageTest {
dataFolder = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
for (String locale : VolmitLocales.nonEnglish()) {
Path target = IrisLanguage.remote(dataFolder).cacheFile(locale);
Path target = dataFolder.toPath().resolve("languages/" + locale + ".toml");
Files.createDirectories(target.getParent());
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".json"), target, StandardCopyOption.REPLACE_EXISTING);
Files.copy(ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".toml"), target, StandardCopyOption.REPLACE_EXISTING);
}
}
@After
public void tearDown() {
IrisLanguage.shutdown();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
}
private LocaleOverlay loadSourceOverlay(String locale) throws Exception {
Path source = ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".json");
Path source = ProjectPaths.moduleFile("src/main/resources/languages").resolve(locale + ".toml");
return IrisLanguage.parseDownloadedOverlay(source.toString(), locale, Files.readString(source));
}
@Test
public void downloadedCatalogSelectsBukkitKeysWithoutAcceptingUnknownOverrides() {
public void languageCatalogSelectsKnownKeysWithoutDiscardingValidTranslations() {
String raw = """
{"locale":"de_DE","messages":{
"iris.command.unknown":"Unbekannter Iris-Befehl",
"iris.modded.help.entry.command.version":"Version anzeigen"
}}
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.modded.help.entry.command.version" = "Version anzeigen"
""";
LocaleOverlay downloaded = IrisLanguage.parseDownloadedOverlay("download", "de_DE", raw);
assertEquals(Set.of(IrisMessages.COMMAND_UNKNOWN.id()), downloaded.values().keySet());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
LocaleOverlay override = IrisLanguage.parseOverlay("override", "de_DE", raw);
assertFalse(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(override)).errors().isEmpty());
LocaleOverlay local = IrisLanguage.parseOverlay("language", "de_DE", raw);
assertEquals(downloaded.values(), local.values());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(local)).errors().isEmpty());
assertEquals("A pregeneration task is already running. Stop it first with /iris pregen stop.",
IrisLanguage.plain(IrisMessages.PREGEN_ALREADY_RUNNING));
}
@Test
public void downloadedCatalogStillRejectsInvalidActivePlaceholders() {
public void invalidPlaceholdersAreExcludedWithoutDiscardingValidMessages() {
LocaleOverlay downloaded = IrisLanguage.parseDownloadedOverlay("download", "de_DE", """
{"locale":"de_DE","messages":{"iris.command.permission_denied":"Keine Erlaubnis"}}
"iris.command.permission_denied" = "Keine Erlaubnis"
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertFalse(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
assertEquals(Set.of(IrisMessages.COMMAND_UNKNOWN.id()), downloaded.values().keySet());
assertTrue(LocalizationValidator.validate(IrisLanguage.catalog(), List.of(downloaded)).errors().isEmpty());
}
@Test
public void missingDownloadUsesCodeOwnedEnglish() throws Exception {
Files.delete(IrisLanguage.remote(dataFolder).cacheFile("de_DE"));
public void missingLanguageUsesBuiltInEnglish() throws Exception {
Files.delete(dataFolder.toPath().resolve("languages/de_DE.toml"));
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@@ -203,7 +205,44 @@ public class IrisLanguageTest {
}
@Test
public void usesCodeOwnedEnglishWithoutLocaleFile() {
public void hotloadRefreshesDefaultAndPersonalLanguagesWithoutChangingSelections() throws Exception {
IrisSettings previous = IrisSettings.settings;
IrisSettings.settings = new IrisSettings();
UUID frenchPlayer = UUID.randomUUID();
IrisLanguage.start();
try {
IrisLanguage.selections().selectDefault("de_DE").get(5, TimeUnit.SECONDS);
IrisLanguage.selections().selectPlayer(frenchPlayer, "fr_FR").get(5, TimeUnit.SECONDS);
String german = "\"iris.command.unknown\" = \"Deutsch aktualisiert\"\n";
File germanFile = writeLanguage("de_DE", german);
assertTrue(IrisLanguage.reloadOverride(germanFile, german));
assertEquals("Deutsch aktualisiert", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
String french = "\"iris.command.unknown\" = \"Francais actualise\"\n";
File frenchFile = writeLanguage("fr_FR", french);
assertTrue(IrisLanguage.reloadOverride(frenchFile, french));
assertEquals("Francais actualise", LanguageAudience.call(frenchPlayer,
() -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
assertEquals("Deutsch aktualisiert", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals("fr_FR", IrisLanguage.selections().playerLocale(frenchPlayer).orElseThrow());
assertEquals("de_DE", IrisLanguage.selections().defaultLocale());
assertEquals("de_DE", IrisSettings.settings.getGeneral().getLanguage());
} finally {
IrisLanguage.shutdown();
IrisSettings.settings = previous;
}
}
@Test
public void generatedEnglishLanguageRoundTripsTheEntireCatalog() throws Exception {
Path english = dataFolder.toPath().resolve("languages/en_US.toml");
assertTrue(Files.isRegularFile(english));
LocaleOverlay overlay = IrisLanguage.parseDownloadedOverlay(english.toString(), "en_US", Files.readString(english));
assertEquals(IrisLanguage.catalog().ids(), overlay.values().keySet());
for (MessageKey key : IrisLanguage.catalog().keys()) {
assertEquals(key.id(), key.englishValue(), overlay.value(key.id()));
}
String rendered = IrisLanguage.plain(
IrisMessages.COMMAND_PERMISSION_DENIED,
MessageArgument.untrusted("permission", "iris.all")
@@ -214,14 +253,128 @@ public class IrisLanguageTest {
}
@Test
public void appliesExternalOverlayAndFallsBackPerKey() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Fehlende Berechtigung: {permission}"
}
}
public void guideDescribesEveryRequiredAndOptionalCatalogPlaceholder() {
Map<String, String> descriptions = IrisLanguageGuide.variableDescriptions();
for (MessageKey key : IrisLanguage.catalog().keys()) {
for (String placeholder : key.placeholders()) {
assertPlaceholderDescription(key.id(), placeholder, descriptions.get(placeholder));
}
for (String placeholder : key.optionalPlaceholders()) {
assertPlaceholderDescription(key.id(), placeholder, descriptions.get(placeholder));
}
}
for (Map.Entry<String, String> entry : descriptions.entrySet()) {
assertPlaceholderDescription("language guide", entry.getKey(), entry.getValue());
}
}
@Test
public void generatedEnglishGuideIncludesMeaningsAndPrefixExamples() throws Exception {
String header = Files.readString(dataFolder.toPath().resolve("languages/en_US.toml"));
assertTrue(header.startsWith("# === Iris language guide ===\n"));
for (Map.Entry<String, String> entry : IrisLanguageGuide.variableDescriptions().entrySet()) {
assertTrue(entry.getKey(), header.contains("# {" + entry.getKey() + "} " + entry.getValue()));
}
assertTrue(header.contains("set_debug = \"&6[Worlds]&r &aSet debug to: {to}\""));
assertTrue(header.contains("&6 gold"));
assertTrue(header.contains("&l bold"));
assertTrue(header.contains("&r reset"));
assertTrue(header.contains("{PC}, {SC} and {TC} are not Iris theme variables"));
}
@Test
public void everyTranslatedGuideDescribesItsVariablesAndMatchesThePackagedGuide() throws Exception {
Pattern definition = Pattern.compile("(?m)^# \\{([A-Za-z][A-Za-z0-9_]*)} (\\S[^\\r\\n]*)$");
for (String locale : VolmitLocales.nonEnglish()) {
String source = Files.readString(ProjectPaths.moduleFile("src/main/resources/languages/" + locale + ".toml"));
int bodyStart = source.indexOf("\n[") + 1;
assertTrue(locale, bodyStart > 0);
String header = source.substring(0, bodyStart);
String body = source.substring(bodyStart);
Map<String, String> descriptions = new LinkedHashMap<>();
Matcher defined = definition.matcher(header);
while (defined.find()) {
assertFalse(locale + ": duplicate " + defined.group(1), descriptions.containsKey(defined.group(1)));
descriptions.put(defined.group(1), defined.group(2));
}
Matcher used = PLACEHOLDER.matcher(body);
while (used.find()) {
String placeholder = used.group().substring(1, used.group().length() - 1);
assertPlaceholderDescription(locale, placeholder, descriptions.get(placeholder));
}
assertEquals(locale, IrisLanguageGuide.variableDescriptions().keySet(), descriptions.keySet());
assertEquals(locale, header.stripTrailing(), IrisLanguageGuide.header(locale).stripTrailing());
assertTrue(locale, header.contains("# === End Iris language guide ==="));
}
}
@Test
public void refreshingAnExistingGuidePreservesCustomMessagesAndOtherComments() throws Exception {
String note = "# Operator's own note\r\n";
String oldGuide = "# === Iris language guide ===\r\n# Iris — en_US\r\n"
+ "# === Variables ===\r\n# {permission} \r\n# {world} \r\n"
+ "# === End Iris language guide ===\r\n";
String body = "\r\n# Keep this explanation\r\n\"iris.command.unknown\" = 'My edited message'\r\n"
+ "[future]\r\nenabled = true\r\nitems = [1, 2]\r\n";
File english = writeLanguage("en_US", note + oldGuide + body);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
String refreshed = Files.readString(english.toPath());
assertEquals(note + IrisLanguageGuide.englishHeader("en_US") + body, refreshed);
assertEquals("My edited message", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
FileTime unchangedTime = FileTime.fromMillis(123_456L);
Files.setLastModifiedTime(english.toPath(), unchangedTime);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
assertEquals(refreshed, Files.readString(english.toPath()));
assertEquals(unchangedTime, Files.getLastModifiedTime(english.toPath()));
}
@Test
public void refreshingATranslatedGuidePreservesItsCustomMessages() throws Exception {
String oldGuide = "# === Iris language guide ===\n# Iris - de_DE\n"
+ "# === Variablen ===\n# {permission} {world}\n# === End Iris language guide ===\n";
String body = "\n[iris.command]\nunknown = 'Eigener Text'\n";
File german = writeLanguage("de_DE", oldGuide + body);
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals(IrisLanguageGuide.header("de_DE") + body, Files.readString(german.toPath()));
assertEquals("Eigener Text", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
public void documentedPrefixExampleUsesItsExistingDebugVariable() throws Exception {
writeLanguage("en_US", "[iris.bukkit.commandiris]\nset_debug = \"&6[Worlds]&r &aSet debug to: {to}\"\n");
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
MessageKey debug = IrisLanguage.catalog().require("iris.bukkit.commandiris.set_debug");
assertEquals("\u00a76[Worlds]\u00a7r \u00a7aSet debug to: true",
IrisLanguage.text(debug, MessageArgument.trusted("to", true)));
assertEquals("[Worlds] Set debug to: false",
IrisLanguage.plain(debug, MessageArgument.trusted("to", false)));
}
@Test
public void unreadableEnglishGuideDoesNotBlockSelectingAnotherLanguage() throws Exception {
Files.write(dataFolder.toPath().resolve("languages/en_US.toml"), new byte[]{(byte) 0xc3, 0x28});
UUID player = UUID.randomUUID();
IrisLanguage.start();
try {
IrisLanguage.selections().selectPlayer(player, "de_DE").get(5, TimeUnit.SECONDS);
assertEquals("de_DE", IrisLanguage.selections().playerLocale(player).orElseThrow());
assertFalse("Unknown Iris command".equals(LanguageAudience.call(player,
() -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN))));
} finally {
IrisLanguage.shutdown();
}
}
@Test
public void appliesLanguageFileAndFallsBackPerKey() throws Exception {
writeLanguage("de_DE", """
"iris.command.permission_denied" = "Fehlende Berechtigung: {permission}"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -232,7 +385,7 @@ public class IrisLanguageTest {
MessageArgument.untrusted("permission", "iris.all")
)
);
assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
@@ -265,11 +418,11 @@ public class IrisLanguageTest {
@Test
public void sourceLocalesExactlyMatchNonEnglishManifest() throws Exception {
Set<String> expected = VolmitLocales.nonEnglish().stream()
.map(locale -> locale + ".json")
.map(locale -> locale + ".toml")
.collect(Collectors.toUnmodifiableSet());
assertEquals(expected, resourceFiles("languages"));
assertFalse(expected.contains(VolmitLocales.ENGLISH + ".json"));
assertFalse(expected.contains(VolmitLocales.ENGLISH + ".toml"));
}
@Test
@@ -289,42 +442,61 @@ public class IrisLanguageTest {
}
@Test
public void downloadedLocaleLoadsWithoutUserOverride() {
public void installedLocaleLoadsDirectly() {
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("de_DE", IrisLanguage.activeLocale());
assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
}
@Test
public void rejectsUnknownKeysAndPlaceholderShapeWhileRetainingLastGood() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Erlaubnis {permission} fehlt"
}
}
public void invalidEntriesUseEnglishWhileValidAndUnknownFileContentIsPreserved() throws Exception {
writeLanguage("de_DE", """
"iris.command.permission_denied" = "Erlaubnis {permission} fehlt"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "Erlaubnis fehlt",
"iris.command.not_real": "Unbekannt"
}
}
""");
String raw = """
"iris.command.permission_denied" = "Erlaubnis fehlt"
"iris.command.unknown" = "Unbekannter Iris-Befehl"
"iris.command.not_real" = "Unbekannt"
""";
File language = writeLanguage("de_DE", raw);
assertFalse(IrisLanguage.reload(dataFolder, "de_DE"));
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals(
"Erlaubnis iris.all fehlt",
"You lack the permission 'iris.all'",
IrisLanguage.plain(
IrisMessages.COMMAND_PERMISSION_DENIED,
MessageArgument.untrusted("permission", "iris.all")
)
);
assertEquals("Unbekannter Iris-Befehl", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(raw, Files.readString(language.toPath()));
}
@Test
public void unreadableTomlUsesEnglishWithoutReplacingTheFile() throws Exception {
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
String raw = "[unterminated";
File language = writeLanguage("de_DE", raw);
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(raw, Files.readString(language.toPath()));
}
@Test
public void englishEditsSurviveReloadAndMissingEnglishIsCreatedForAnotherLocale() throws Exception {
String edited = "\"iris.command.unknown\" = \"My Iris command\"\n";
File english = writeLanguage("en_US", edited);
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
assertEquals("My Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertEquals(edited, Files.readString(english.toPath()));
Files.delete(english.toPath());
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertTrue(Files.isRegularFile(english.toPath()));
assertEquals("de_DE", IrisLanguage.activeLocale());
}
@Test
@@ -349,13 +521,8 @@ public class IrisLanguageTest {
@Test
public void untrustedArgumentsCannotInjectLegacyOrMiniMessageFormatting() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.permission_denied": "&aWert: {permission}"
}
}
writeLanguage("de_DE", """
"iris.command.permission_denied" = "&aWert: {permission}"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -385,28 +552,20 @@ public class IrisLanguageTest {
}
@Test
public void lookupsUseImmutableSnapshotWithoutReadingTheOverrideAgain() throws Exception {
File override = writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.command.unknown": "Unbekannter Iris-Befehl"
}
}
public void lookupsUseImmutableSnapshotWithoutReadingTheLanguageFileAgain() throws Exception {
File language = writeLanguage("de_DE", """
"iris.command.unknown" = "Unbekannter Iris-Befehl"
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
Files.delete(override.toPath());
Files.delete(language.toPath());
assertEquals("Unbekannter Iris-Befehl", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
public void appliesLineAndPluralOverlayShapes() throws Exception {
writeOverride("de_DE", """
{
"locale": "de_DE",
"messages": {
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning": [
writeLanguage("de_DE", """
"iris.bukkit.runtime.commanddeveloper.stage_world_generation_update_warning" = [
"Sicherung erstellen.",
"Vorhandene Chunks bleiben erhalten.",
"Neue Chunks verwenden das neue Paket.",
@@ -414,13 +573,10 @@ public class IrisLanguageTest {
"Den vollständigen Verlauf sichern.",
"Neustart bestätigen.",
"Welt {world}, Paket {pack}"
],
"iris.bukkit.runtime.commandpack.more_warning_s": {
"one": "Noch {count} Warnung.",
"other": "Noch {count} Warnungen."
}
}
}
]
["iris.bukkit.runtime.commandpack.more_warning_s"]
one = "Noch {count} Warnung."
other = "Noch {count} Warnungen."
""");
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
@@ -454,13 +610,21 @@ public class IrisLanguageTest {
);
}
private File writeOverride(String locale, String json) throws Exception {
File file = new File(dataFolder, "languages/overrides/" + locale + ".json");
private File writeLanguage(String locale, String toml) throws Exception {
File file = new File(dataFolder, "languages/" + locale + ".toml");
Files.createDirectories(file.toPath().getParent());
Files.writeString(file.toPath(), json, StandardCharsets.UTF_8);
Files.writeString(file.toPath(), toml, StandardCharsets.UTF_8);
return file;
}
private void assertPlaceholderDescription(String key, String placeholder, String description) {
String context = key + " placeholder {" + placeholder + "}";
assertNotNull(context + " needs a definition", description);
assertFalse(context + " definition must not be blank", description.isBlank());
assertFalse(context + " definition must explain the variable", placeholder.equalsIgnoreCase(description.strip()));
assertFalse(context + " definition must explain the variable", ("{" + placeholder + "}").equals(description.strip()));
}
private void assertValueIntegrity(String locale, String key, MessageValue english, MessageValue translated) {
if (english instanceof TextValue englishText && translated instanceof TextValue translatedText) {
assertTemplateIntegrity(locale, key, englishText.template(), translatedText.template());