Download translations on demand instead of bundling them

Locales are fetched from the Iris repository at runtime into
languages/downloaded and cached against the revision the jar was built
from; the artifact ships only a manifest of the available locales.
IrisLanguage gains a player language service, per-audience snapshots and
an in-game message editor that writes locale overrides. The Bukkit
artifact verifier now fails when any locale file is embedded, and the
modded shadow jars drop the Bukkit-only switcher and editor classes.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-03 05:03:21 -04:00
parent 5053a644c7
commit b25937f986
10 changed files with 472 additions and 191 deletions
+3
View File
@@ -260,6 +260,9 @@ tasks.named('shadowJar', ShadowJar).configure {
// archive as versioned and re-scan it per release directory for nothing.
exclude('META-INF/versions/**')
addMultiReleaseAttribute.set(false)
exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class')
exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class')
exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class')
// Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it.
exclude('org/apache/commons/lang/enum/**')
// Compile-only annotation carriers. Shipping them duplicates packages other mods also ship.
+3
View File
@@ -252,6 +252,9 @@ tasks.named('shadowJar', ShadowJar).configure {
// archive as versioned and re-scan it per release directory for nothing.
exclude('META-INF/versions/**')
addMultiReleaseAttribute.set(false)
exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class')
exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class')
exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class')
// Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it.
exclude('org/apache/commons/lang/enum/**')
// Compile-only annotation carriers. Shipping them duplicates packages other mods also ship.
+3
View File
@@ -228,6 +228,9 @@ tasks.named('shadowJar', ShadowJar).configure {
// archive as versioned and re-scan it per release directory for nothing.
exclude('META-INF/versions/**')
addMultiReleaseAttribute.set(false)
exclude('art/arcane/volmlib/util/localization/BukkitLanguageSwitcher*.class')
exclude('art/arcane/volmlib/util/localization/BukkitLanguageEditor*.class')
exclude('art/arcane/volmlib/util/localization/BukkitVolmitCommand*.class')
// Invalid package name on Java 9+ ('enum' is a keyword); a module-path scan chokes on it.
exclude('org/apache/commons/lang/enum/**')
// Compile-only annotation carriers. Shipping them duplicates packages other mods also ship.
+1 -58
View File
@@ -3,8 +3,6 @@ import org.gradle.api.tasks.Copy
import org.gradle.api.tasks.compile.JavaCompile
import org.gradle.jvm.tasks.Jar
import org.gradle.jvm.toolchain.JavaLanguageVersion
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
/*
* Iris is a World Generator for Minecraft Bukkit Servers
@@ -147,16 +145,6 @@ nmsBindings.each { key, value ->
def included = configurations.create('included')
def jarJar = configurations.create('jarJar')
def bukkitLanguagesDirectory = layout.buildDirectory.dir('generated/bukkit-languages')
Set<String> sharedNonEnglishBukkitMessages = [
'iris.desktop.vision.fps',
'iris.desktop.vision.tiles',
'iris.bukkit.runtime.commandpack.message_2',
'iris.bukkit.commanddatapack.message_2',
'iris.runtime.chunk_job.bossbar.progress',
'iris.runtime.studio.action.progress',
'iris.runtime.chunk_job.action.progress'
] as Set
dependencies {
nmsBindings.keySet().each { key ->
add('included', project(path: ":adapters:bukkit:nms:${key}", configuration: 'runtimeElements'))
@@ -166,48 +154,6 @@ dependencies {
add('jarJar', project(':core:agent'))
}
def prepareBukkitLanguages = tasks.register('prepareBukkitLanguages') {
inputs.files(fileTree(project(':core').file('src/main/resources/languages')) {
include('*.json')
})
outputs.dir(bukkitLanguagesDirectory)
doLast {
File outputDirectory = bukkitLanguagesDirectory.get().asFile
delete(outputDirectory)
outputDirectory.mkdirs()
List<File> sources = fileTree(project(':core').file('src/main/resources/languages')) {
include('*.json')
}.files.sort { File left, File right -> left.name <=> right.name }
List<Map<String, Object>> catalogs = sources.collect { File source ->
(Map<String, Object>) new JsonSlurper().parse(source)
}
Map<String, Object> referenceMessages = (Map<String, Object>) catalogs.first().get('messages')
List<String> messageIds = referenceMessages.keySet()
.findAll { String key -> !key.startsWith('iris.modded.') }
.sort()
Set<String> sharedFallbackIds = new LinkedHashSet<>(messageIds)
catalogs.each { Map<String, Object> catalog ->
Map<String, Object> messages = (Map<String, Object>) catalog.get('messages')
if (!messages.keySet().containsAll(messageIds)) {
throw new GradleException("Bundled locale ${catalog.get('locale')} is missing Bukkit messages")
}
sharedFallbackIds.removeIf { String key -> messages.get(key) != referenceMessages.get(key) }
}
sharedFallbackIds.removeAll(sharedNonEnglishBukkitMessages)
sources.eachWithIndex { File source, int index ->
Map<String, Object> catalog = catalogs.get(index)
Map<String, Object> messages = (Map<String, Object>) catalog.get('messages')
List<Object> compactMessages = messageIds.collect { String key ->
sharedFallbackIds.contains(key) ? null : messages.get(key)
}
new File(outputDirectory, source.name).setText(
JsonOutput.toJson([catalog.get('locale'), compactMessages]),
'UTF-8')
}
}
}
tasks.named('jar', Jar).configure {
inputs.files(included)
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
@@ -216,9 +162,6 @@ tasks.named('jar', Jar).configure {
from(provider { included.resolve().collect { zipTree(it) } }) {
exclude('languages/**')
}
from(prepareBukkitLanguages) {
into('languages')
}
doFirst {
delete(layout.buildDirectory.file("libs/Iris-${project.version}.jar"))
}
@@ -591,6 +534,7 @@ List<String> requiredBukkitArtifactEntries = [
'paper-plugin.yml',
'slimjar.dat',
'slimjar-resolutions.dat',
'iris-language-source.properties',
'agent.jar',
'art/arcane/iris/Iris.class',
'art/arcane/iris/IrisBootstrap.class',
@@ -618,7 +562,6 @@ tasks.register('verifyBukkitArtifact') {
BukkitArtifactVerifier.verify(
artifact,
requiredBukkitArtifactEntries,
17,
16,
maximumBukkitArtifactBytes)
logger.lifecycle("Verified ${artifact.name} packaging and class reference graph")
@@ -47,7 +47,7 @@ public final class BukkitArtifactVerifier {
private BukkitArtifactVerifier() {
}
public static void verify(File artifact, List<String> requiredEntries, int minimumLocales,
public static void verify(File artifact, List<String> requiredEntries,
int minimumMatterSlices, long maximumArtifactBytes) {
if (!artifact.isFile()) {
throw new GradleException("Missing Bukkit Iris artifact: " + artifact.getAbsolutePath());
@@ -92,9 +92,8 @@ public final class BukkitArtifactVerifier {
}
}
if (locales < minimumLocales) {
throw new GradleException(artifact.getName() + " ships " + locales + " locale files; expected at least "
+ minimumLocales);
if (locales != 0) {
throw new GradleException(artifact.getName() + " ships " + locales + " locale files; translations must download on demand");
}
// Matter.read() resolves slice types from the canonical name stored in the payload.
if (matterSlices < minimumMatterSlices) {
@@ -29,7 +29,6 @@ public class BukkitArtifactVerifierTest {
SLIMJAR_RESOLUTIONS,
NMS_BINDING + ".class"
);
private static final int LOCALES = 2;
private static final int MATTER_SLICES = 1;
@Rule
@@ -39,7 +38,7 @@ public class BukkitArtifactVerifierTest {
public void acceptsCompleteArtifact() throws Exception {
File artifact = createArtifact(validEntries());
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE);
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE);
}
@Test
@@ -49,7 +48,7 @@ public class BukkitArtifactVerifierTest {
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains(PLUGIN_DESCRIPTOR));
}
@@ -60,7 +59,7 @@ public class BukkitArtifactVerifierTest {
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains(SLIMJAR_DEPENDENCIES));
}
@@ -71,7 +70,7 @@ public class BukkitArtifactVerifierTest {
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains(SLIMJAR_RESOLUTIONS));
}
@@ -82,7 +81,7 @@ public class BukkitArtifactVerifierTest {
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains("art/arcane/volmlib/util/noise/CNG"));
}
@@ -101,7 +100,7 @@ public class BukkitArtifactVerifierTest {
classReferencing("art/arcane/iris/ParalithicConsumer", "art/arcane/iris/util/paralithic/functions/Function"));
File artifact = createArtifact(entries);
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE);
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE);
}
@Test
@@ -112,20 +111,19 @@ public class BukkitArtifactVerifierTest {
() -> BukkitArtifactVerifier.verify(
artifact,
REQUIRED_ENTRIES,
LOCALES,
MATTER_SLICES,
artifact.length() - 1L));
assertTrue(failure.getMessage().contains("must not exceed"));
}
@Test
public void rejectsDroppedLocale() throws Exception {
public void rejectsEmbeddedLocale() throws Exception {
Map<String, byte[]> entries = validEntries();
entries.remove("languages/de_DE.json");
entries.put("languages/de_DE.json", "{}".getBytes(StandardCharsets.UTF_8));
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains("locale files"));
}
@@ -136,7 +134,7 @@ public class BukkitArtifactVerifierTest {
File artifact = createArtifact(entries);
GradleException failure = assertThrows(GradleException.class,
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE));
() -> BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE));
assertTrue(failure.getMessage().contains("Matter slice types"));
}
@@ -147,7 +145,7 @@ public class BukkitArtifactVerifierTest {
classAnnotatedWith("art/arcane/iris/Annotated", "com/google/errorprone/annotations/CanIgnoreReturnValue"));
File artifact = createArtifact(entries);
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, LOCALES, MATTER_SLICES, Long.MAX_VALUE);
BukkitArtifactVerifier.verify(artifact, REQUIRED_ENTRIES, MATTER_SLICES, Long.MAX_VALUE);
}
private Map<String, byte[]> validEntries() {
@@ -155,8 +153,7 @@ public class BukkitArtifactVerifierTest {
entries.put(PLUGIN_DESCRIPTOR, "name: Iris\n".getBytes(StandardCharsets.UTF_8));
entries.put(SLIMJAR_DEPENDENCIES, new byte[]{1});
entries.put(SLIMJAR_RESOLUTIONS, new byte[]{1});
entries.put("languages/de_DE.json", "{}".getBytes(StandardCharsets.UTF_8));
entries.put("languages/fr_FR.json", "{}".getBytes(StandardCharsets.UTF_8));
entries.put(NMS_BINDING + ".class",
classReferencing(NMS_BINDING, "art/arcane/volmlib/util/noise/CNG"));
entries.put("art/arcane/volmlib/util/noise/CNG.class", emptyClass("art/arcane/volmlib/util/noise/CNG"));
+20 -1
View File
@@ -38,6 +38,24 @@ String volmLibCoordinate = providers.gradleProperty('volmLibCoordinate')
.orElse('com.github.VolmitSoftware:VolmLib:e6574cf814b3dd670385a4d632a63fde2038e186')
.get()
String languageSourceReference = providers.gradleProperty('languageRevision').orElse('master').get()
List<File> languageSourceFiles = fileTree('src/main/resources/languages') {
include('*.json')
}.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())
}
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(','))
}
tasks.named('processResources').configure {
dependsOn(languageSourceManifestTask)
exclude('languages/**')
from(languageSourceManifestTask)
}
/**
* Dependencies.
*
@@ -218,7 +236,8 @@ tasks.named('compileJava', JavaCompile).configure {
tasks.named('test', Test).configure {
jvmArgs('--add-modules', 'jdk.incubator.vector')
classpath = files('src/main/resources') + classpath
dependsOn(languageSourceManifestTask)
classpath = files(layout.buildDirectory.dir('generated/language-source')) + files('src/main/resources') + classpath
}
configurations.matching { it.name.startsWith('slim') }.all { }
@@ -5,6 +5,12 @@ import art.arcane.iris.spi.IrisLogging;
import art.arcane.iris.spi.IrisPlatforms;
import art.arcane.volmlib.util.director.DirectorTextResolver;
import art.arcane.volmlib.util.localization.LinesKey;
import art.arcane.volmlib.util.localization.LanguageAudience;
import art.arcane.volmlib.util.localization.LanguageFileEditor;
import art.arcane.volmlib.util.localization.PluginLanguageEditor;
import art.arcane.volmlib.util.localization.PluginLanguageService;
import art.arcane.volmlib.util.localization.RemoteLanguageCatalog;
import art.arcane.volmlib.util.localization.LocalizationValidator;
import art.arcane.volmlib.util.localization.LocaleOverlay;
import art.arcane.volmlib.util.localization.LocalizationCandidate;
import art.arcane.volmlib.util.localization.LocalizationIssue;
@@ -16,20 +22,32 @@ import art.arcane.volmlib.util.localization.MessageArgumentKind;
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 art.arcane.volmlib.util.localization.VolmitLocales;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
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.StandardCopyOption;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Logger;
import com.google.gson.Gson;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.StandardCharsets;
@@ -51,15 +69,12 @@ import java.util.function.BiConsumer;
import java.util.regex.Pattern;
public final class IrisLanguage {
private static final Object SNAPSHOT_LOCK = new Object();
private static final long MAX_LOCALE_BYTES = 2L * 1024L * 1024L;
private static final int MAX_REPORTED_ISSUES = 12;
private static final Pattern LOCALE_NAME = Pattern.compile("[A-Za-z0-9_-]+");
private static final Pattern LEGACY_COLOR = Pattern.compile("(?i)\\u00a7[0-9A-FK-ORX]");
private static final MessageCatalog CATALOG = IrisMessages.catalog();
private static final List<String> BUKKIT_MESSAGE_IDS = CATALOG.ids().stream()
.filter(id -> !id.startsWith("iris.modded."))
.sorted()
.toList();
private static final LocalizationManager MANAGER = new LocalizationManager(
LocalizationCandidate.english(CATALOG, PluralSelector.oneOther())
);
@@ -74,6 +89,9 @@ public final class IrisLanguage {
new CopyOnWriteArrayList<>();
private static volatile File dataFolder;
private static volatile String activeLocale = CATALOG.englishLocale();
private static volatile RemoteLanguageCatalog remote;
private static volatile Path remoteRoot;
private static volatile PluginLanguageService selections;
private IrisLanguage() {
}
@@ -82,7 +100,9 @@ public final class IrisLanguage {
if (!IrisPlatforms.isBound()) {
return false;
}
return reload(IrisPlatforms.get().dataFolder(), configuredLocale());
boolean loaded = reload(IrisPlatforms.get().dataFolder(), configuredLocale());
start();
return loaded;
}
public static synchronized boolean reload() {
@@ -201,6 +221,7 @@ public final class IrisLanguage {
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()));
} else {
@@ -209,12 +230,21 @@ public final class IrisLanguage {
});
}
dataFolder = root;
if (result.applied()) {
activeLocale = requestedLocale;
}
}
if (!result.applied()) {
reportRejectedReload(requestedLocale, result);
return false;
}
activeLocale = requestedLocale;
PluginLanguageService current = selections;
if (current != null) {
current.invalidate();
current.cache(activeLocale, MANAGER.snapshot());
requestConfiguredLocale();
}
int warnings = result.validation().warnings().size();
IrisLogging.debug("Loaded locale " + requestedLocale + " with " + warnings + " fallback "
+ (warnings == 1 ? "entry" : "entries") + ".");
@@ -274,7 +304,7 @@ public final class IrisLanguage {
}
public static String plain(MessageKey key) {
LocalizationSnapshot snapshot = MANAGER.snapshot();
LocalizationSnapshot snapshot = snapshot();
PlainMemo memo = PLAIN_MEMO.get();
if (memo == null || memo.snapshot() != snapshot) {
memo = new PlainMemo(snapshot, new ConcurrentHashMap<>());
@@ -284,7 +314,7 @@ public final class IrisLanguage {
if (cached != null) {
return cached;
}
String resolved = plain(key, MessageArgs.empty());
String resolved = IrisLogging.clean(render(resolve(snapshot, key, MessageArgs.empty())));
memo.values().put(key.id(), resolved);
return resolved;
}
@@ -317,14 +347,18 @@ public final class IrisLanguage {
}
private static ResolvedText resolve(MessageKey key, MessageArgs arguments) {
return resolve(snapshot(), key, arguments);
}
private static ResolvedText resolve(LocalizationSnapshot snapshot, MessageKey key, MessageArgs arguments) {
if (key instanceof TextKey textKey) {
return MANAGER.snapshot().resolve(textKey, arguments);
return snapshot.resolve(textKey, arguments);
}
if (key instanceof PluralKey pluralKey) {
return MANAGER.snapshot().resolve(pluralKey, arguments);
return snapshot.resolve(pluralKey, arguments);
}
if (key instanceof LinesKey linesKey) {
ResolvedLines lines = MANAGER.snapshot().resolve(linesKey, arguments);
ResolvedLines lines = snapshot.resolve(linesKey, arguments);
return new ResolvedText(lines.key(), lines.locale(), String.join("\n", lines.lines()), lines.arguments());
}
throw new IllegalArgumentException("Unsupported Iris message key: " + key.id());
@@ -343,41 +377,216 @@ public final class IrisLanguage {
}
if (!CATALOG.englishLocale().equals(locale)) {
LocaleOverlay bundled = loadBundledOverlay(locale);
if (bundled != null) {
overlays.add(bundled);
LocaleOverlay downloaded = loadDownloadedOverlay(root, locale);
if (downloaded != null) {
overlays.add(downloaded);
}
}
return new LocalizationCandidate(CATALOG, overlays, PluralSelector.oneOther());
}
static LocaleOverlay loadBundledOverlay(String locale) throws Exception {
String normalizedLocale = normalizeLocale(locale);
if (CATALOG.englishLocale().equals(normalizedLocale)) {
return null;
static RemoteLanguageCatalog remote(File root) {
Path path = root.toPath().toAbsolutePath().normalize();
RemoteLanguageCatalog current = remote;
if (current != null && path.equals(remoteRoot)) {
return current;
}
String resourceName = "/languages/" + normalizedLocale + ".json";
InputStream input = IrisLanguage.class.getResourceAsStream(resourceName);
if (input == null) {
if (VolmitLocales.isBundled(normalizedLocale)) {
throw new IllegalStateException("Missing bundled Iris locale resource: " + resourceName);
synchronized (IrisLanguage.class) {
if (remote != null && path.equals(remoteRoot)) {
return remote;
}
if (remote != null) {
remote.close();
}
remote = RemoteLanguageCatalog.load(new RemoteLanguageCatalog.Options(
"Iris",
URI.create("https://raw.githubusercontent.com/VolmitSoftware/Iris/"),
"core/src/main/resources/languages",
".json",
"iris-language-source.properties",
path.resolve("languages/downloaded"),
IrisLanguage.class.getClassLoader()
));
remoteRoot = path;
return remote;
}
}
public static synchronized void start() {
if (selections != null || dataFolder == null) {
return;
}
selections = new PluginLanguageService(new PluginLanguageService.Options(
dataFolder.toPath().resolve("languages/players.properties"),
IrisLanguage::availableLocales,
IrisLanguage::activeLocale,
MANAGER::snapshot,
IrisLanguage::prepareLocale,
IrisLanguage::selectDefault,
Logger.getLogger("Iris")
));
requestConfiguredLocale();
}
public static synchronized void shutdown() {
PluginLanguageService current = selections;
selections = null;
if (current != null) {
current.close();
}
RemoteLanguageCatalog currentRemote = remote;
remote = null;
remoteRoot = null;
if (currentRemote != null) {
currentRemote.close();
}
}
public static PluginLanguageService selections() {
return selections;
}
public static Set<String> availableLocales() {
Set<String> locales = new LinkedHashSet<>();
locales.add(CATALOG.englishLocale());
locales.addAll(remote(dataFolder).availableLocales());
return Set.copyOf(locales);
}
public static String text(UUID player, MessageKey key, MessageArgs arguments) {
return LanguageAudience.call(player, () -> text(key, arguments));
}
public static String plain(UUID player, MessageKey key, MessageArgs arguments) {
return LanguageAudience.call(player, () -> plain(key, arguments));
}
private static LocalizationSnapshot snapshot() {
PluginLanguageService current = selections;
return current == null ? MANAGER.snapshot() : current.snapshot();
}
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);
}
SnapshotCapture capture = captureForReload(overrideFile(root, locale), locale);
if (capture.failure() != null) {
throw capture.failure();
}
return LocalizationSnapshot.create(loadCandidate(root, locale, capture.snapshot()));
}
public static PluginLanguageEditor.Options editorOptions() {
return new PluginLanguageEditor.Options(IrisLanguage::prepareLocale, IrisLanguage::writeMessage);
}
private static LocalizationSnapshot writeMessage(PluginLanguageEditor.Edit edit) throws IOException {
File root = dataFolder;
File file = overrideFile(root, edit.locale());
LocalizationSnapshot prepared = LanguageFileEditor.update(file.toPath(), raw -> {
LocalizationSnapshot current = editorSnapshot(root, file, edit.locale(), raw);
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);
return new LanguageFileEditor.Prepared<>(updated, editorSnapshot(root, file, edit.locale(), updated));
});
synchronized (SNAPSHOT_LOCK) {
if (root.equals(dataFolder) && edit.locale().equals(activeLocale)) {
MANAGER.install(prepared);
}
}
return prepared;
}
private static LocalizationSnapshot editorSnapshot(File root, File file, String locale, String raw) throws IOException {
try {
LocaleHotloadSnapshot source = LocaleHotloadSnapshot.present(file, locale, raw, sha256(raw.getBytes(StandardCharsets.UTF_8)));
return LocalizationSnapshot.create(loadCandidate(root, locale, source));
} catch (Exception failure) {
throw new IOException("Could not validate Iris language " + locale, failure);
}
}
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();
serialized.getAsJsonObject("general").addProperty("language", locale);
Path target = dataFolder.toPath().resolve("iris.json");
Path temporary = Files.createTempFile(target.getParent(), "iris-", ".json.tmp");
try {
Files.writeString(temporary, serialized.toString());
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(temporary);
}
current.getGeneral().setLanguage(locale);
synchronized (SNAPSHOT_LOCK) {
MANAGER.install(prepared);
activeLocale = locale;
}
}
private static void requestConfiguredLocale() {
String locale = activeLocale;
if (CATALOG.englishLocale().equals(locale)) {
return;
}
File root = dataFolder;
remote(root).request(locale, IrisLanguage::validateDownload, result -> {
if (!result.successful()) {
IrisLogging.error("Failed to download Iris locale " + locale + ".");
IrisLogging.reportError(result.failure());
return;
}
synchronized (IrisLanguage.class) {
if (root.equals(dataFolder) && locale.equals(activeLocale)) {
reloadResolved(root, locale, false);
}
}
});
}
private static void validateDownload(String locale, String raw) {
LocalizationValidator.validate(CATALOG, List.of(parseOverlay("download:" + locale, locale, raw)))
.throwIfInvalid();
}
private static LocaleOverlay loadDownloadedOverlay(File root, String locale) {
RemoteLanguageCatalog.CacheResult cached = remote(root).read(locale, IrisLanguage::validateDownload);
if (cached.state() == RemoteLanguageCatalog.CacheState.VALID) {
return parseOverlay(cached.file().toString(), locale, cached.content());
}
if (cached.failure() != null) {
IrisLogging.error("Ignoring invalid downloaded Iris locale " + locale + ".");
IrisLogging.reportError(cached.failure());
}
return null;
}
try (InputStream stream = input) {
byte[] bytes = stream.readNBytes((int) MAX_LOCALE_BYTES + 1);
if (bytes.length > MAX_LOCALE_BYTES) {
throw new IllegalArgumentException("Bundled locale is too large: " + resourceName);
}
return parseOverlay("bundled:" + resourceName, normalizedLocale, new String(bytes, StandardCharsets.UTF_8));
}
}
static LocaleOverlay parseOverlay(String source, String locale, String raw) {
JsonElement parsed = JsonParser.parseString(raw == null || raw.isBlank() ? "{}" : raw);
if (parsed.isJsonArray()) {
return parseCompactBukkitOverlay(source, locale, parsed.getAsJsonArray());
}
if (!parsed.isJsonObject()) {
throw new IllegalArgumentException("Locale source is not a JSON object: " + source);
}
@@ -405,49 +614,6 @@ public final class IrisLanguage {
return builder.build();
}
private static LocaleOverlay parseCompactBukkitOverlay(
String source,
String locale,
JsonArray root
) {
if (root.size() != 2 || !root.get(0).isJsonPrimitive() || !root.get(1).isJsonArray()) {
throw new IllegalArgumentException("Compact locale source is invalid: " + source);
}
if (!locale.equals(normalizeLocale(root.get(0).getAsString()))) {
throw new IllegalArgumentException("Locale source declares a different locale than its file: " + source);
}
JsonArray messages = root.get(1).getAsJsonArray();
if (messages.size() != BUKKIT_MESSAGE_IDS.size()) {
throw new IllegalArgumentException("Compact locale message count is invalid: " + source);
}
LocaleOverlay.Builder builder = LocaleOverlay.builder(source, locale);
for (int i = 0; i < messages.size(); i++) {
JsonElement value = messages.get(i);
if (value == null || value.isJsonNull()) {
continue;
}
appendCompactMessage(builder, BUKKIT_MESSAGE_IDS.get(i), value);
}
return builder.build();
}
private static void appendCompactMessage(
LocaleOverlay.Builder builder,
String key,
JsonElement value
) {
MessageKey definition = CATALOG.key(key);
if (value.isJsonObject() && definition instanceof PluralKey) {
builder.plural(key, readPlural(key, value.getAsJsonObject()));
} 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("Compact locale value has an invalid shape: " + key);
}
}
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();
@@ -0,0 +1,110 @@
package art.arcane.iris.core.localization;
import art.arcane.volmlib.util.localization.LocalizationCandidate;
import art.arcane.volmlib.util.localization.LocalizationSnapshot;
import art.arcane.volmlib.util.localization.PluginLanguageEditor;
import art.arcane.volmlib.util.localization.PluginLanguageService;
import art.arcane.volmlib.util.localization.PluralSelector;
import art.arcane.volmlib.util.localization.TextValue;
import art.arcane.volmlib.util.localization.VolmitLocales;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
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;
public class IrisLanguageEditorTest {
@Rule
public TemporaryFolder temporaryFolder = new TemporaryFolder();
private File directory;
private PluginLanguageService languages;
private PluginLanguageEditor editor;
@Before
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(Path.of("src/main/resources/languages/fr_FR.json"), cache);
PluginLanguageEditor.Options options = IrisLanguage.editorOptions();
LocalizationSnapshot english = LocalizationSnapshot.create(
LocalizationCandidate.english(IrisLanguage.catalog(), PluralSelector.oneOther()));
languages = new PluginLanguageService(new PluginLanguageService.Options(
directory.toPath().resolve("players.properties"), VolmitLocales::all, () -> "en_US", () -> english,
options.loader()::load, (locale, snapshot) -> {
throw new AssertionError("Editing must not select a server language");
}, Logger.getLogger("IrisLanguageEditorTest")));
editor = new PluginLanguageEditor(languages, options);
}
@After
public void closeEditor() {
editor.close();
languages.close();
assertTrue(IrisLanguage.reload(directory, "en_US"));
IrisLanguage.shutdown();
}
@Test
public void savesOneLocaleAndRefreshesItsPersonalSnapshotWithoutSelectingIt() throws Exception {
UUID player = UUID.randomUUID();
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");
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")));
}
@Test
public void invalidMessageLeavesTheLocaleFileIntact() throws Exception {
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
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");
byte[] before = Files.readAllBytes(file);
PluginLanguageEditor.Document saved = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
assertThrows(ExecutionException.class, () -> editor.save(new PluginLanguageEditor.Edit("fr_FR",
IrisMessages.COMMAND_RELOAD_SUCCESS.id(), saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS),
new TextValue("Missing placeholder"))).get(5, TimeUnit.SECONDS));
assertArrayEquals(before, Files.readAllBytes(file));
}
@Test
public void incompleteDownloadedLocaleCanBeEditedWithoutBeingSelected() throws Exception {
Files.writeString(IrisLanguage.remote(directory).cacheFile("fr_FR"), "{\"locale\":\"fr_FR\",\"messages\":{}}");
PluginLanguageEditor.Document original = editor.load("fr_FR").get(5, TimeUnit.SECONDS);
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);
assertEquals(value, saved.snapshot().value(IrisMessages.COMMAND_RELOAD_SUCCESS));
assertEquals("en_US", languages.defaultLocale());
assertEquals("en_US", IrisLanguage.activeLocale());
}
}
@@ -23,12 +23,17 @@ import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.ExecutionException;
import art.arcane.volmlib.util.localization.LanguageAudience;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -39,6 +44,7 @@ import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
public class IrisLanguageTest {
private static final Pattern PLACEHOLDER = Pattern.compile("\\{[A-Za-z][A-Za-z0-9_]*}");
@@ -108,6 +114,11 @@ public class IrisLanguageTest {
public void setUp() throws Exception {
dataFolder = temporaryFolder.newFolder();
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
for (String locale : VolmitLocales.nonEnglish()) {
Path target = IrisLanguage.remote(dataFolder).cacheFile(locale);
Files.createDirectories(target.getParent());
Files.copy(Path.of("src/main/resources/languages", locale + ".json"), target, StandardCopyOption.REPLACE_EXISTING);
}
}
@After
@@ -115,6 +126,56 @@ public class IrisLanguageTest {
assertTrue(IrisLanguage.reload(dataFolder, "en_US"));
}
private LocaleOverlay loadSourceOverlay(String locale) throws Exception {
Path source = Path.of("src/main/resources/languages", locale + ".json");
return IrisLanguage.parseOverlay(source.toString(), locale, Files.readString(source));
}
@Test
public void missingDownloadUsesCodeOwnedEnglish() throws Exception {
Files.delete(IrisLanguage.remote(dataFolder).cacheFile("de_DE"));
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
}
@Test
public void failedServerSelectionRetainsSettingsAndRenderedLanguage() throws Exception {
IrisSettings previous = IrisSettings.settings;
IrisSettings current = new IrisSettings();
IrisSettings.settings = current;
Files.createDirectory(dataFolder.toPath().resolve("iris.json"));
IrisLanguage.start();
try {
assertThrows(ExecutionException.class,
() -> IrisLanguage.selections().selectDefault("de_DE").get(5, TimeUnit.SECONDS));
assertEquals("en_US", current.getGeneral().getLanguage());
assertEquals("en_US", IrisLanguage.activeLocale());
assertEquals("Unknown Iris command", IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
} finally {
IrisLanguage.shutdown();
IrisSettings.settings = previous;
}
}
@Test
public void personalSelectionDoesNotChangeAnotherPlayerOrTheServerDefault() throws Exception {
UUID germanPlayer = UUID.randomUUID();
UUID otherPlayer = UUID.randomUUID();
IrisLanguage.start();
try {
IrisLanguage.selections().selectPlayer(germanPlayer, "de_DE").get(5, TimeUnit.SECONDS);
String german = LanguageAudience.call(germanPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
String other = LanguageAudience.call(otherPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN));
assertFalse("Unknown Iris command".equals(german));
assertEquals("Unknown Iris command", other);
assertEquals("en_US", IrisLanguage.activeLocale());
IrisLanguage.selections().clearPlayer(germanPlayer).get(5, TimeUnit.SECONDS);
assertEquals("Unknown Iris command", LanguageAudience.call(germanPlayer, () -> IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));
} finally {
IrisLanguage.shutdown();
}
}
@Test
public void usesCodeOwnedEnglishWithoutLocaleFile() {
String rendered = IrisLanguage.plain(
@@ -149,10 +210,10 @@ public class IrisLanguageTest {
}
@Test
public void bundledLocalesMatchSharedManifestAndCoverEntireCatalog() throws Exception {
public void downloadableLocalesMatchSharedManifestAndCoverEntireCatalog() throws Exception {
assertEquals(17, VolmitLocales.nonEnglish().size());
for (String locale : VolmitLocales.nonEnglish()) {
LocaleOverlay overlay = IrisLanguage.loadBundledOverlay(locale);
LocaleOverlay overlay = loadSourceOverlay(locale);
assertEquals(locale, overlay.locale());
assertEquals(IrisLanguage.catalog().ids(), overlay.values().keySet());
@@ -175,30 +236,7 @@ public class IrisLanguageTest {
}
@Test
public void compactBukkitLocaleUsesSortedCatalogPositionsAndEnglishFallbacks() {
List<String> messageIds = IrisLanguage.catalog().ids().stream()
.filter(id -> !id.startsWith("iris.modded."))
.sorted()
.toList();
String translatedId = IrisMessages.COMMAND_UNKNOWN.id();
JsonArray values = new JsonArray(messageIds.size());
for (int i = 0; i < messageIds.size(); i++) {
values.add(messageIds.get(i).equals(translatedId) ? "Unbekannter Iris-Befehl" : null);
}
JsonArray compact = new JsonArray(2);
compact.add("de_DE");
compact.add(values);
LocaleOverlay overlay = IrisLanguage.parseOverlay("test", "de_DE", compact.toString());
assertEquals(Set.of(translatedId), overlay.values().keySet());
assertEquals(
"Unbekannter Iris-Befehl",
((TextValue) overlay.value(translatedId)).template());
}
@Test
public void bundledServerResourcesExactlyMatchNonEnglishManifest() throws Exception {
public void sourceLocalesExactlyMatchNonEnglishManifest() throws Exception {
Set<String> expected = VolmitLocales.nonEnglish().stream()
.map(locale -> locale + ".json")
.collect(Collectors.toUnmodifiableSet());
@@ -208,9 +246,9 @@ public class IrisLanguageTest {
}
@Test
public void bundledLocalesPreserveReservedWorldNames() throws Exception {
public void downloadableLocalesPreserveReservedWorldNames() throws Exception {
for (String locale : VolmitLocales.nonEnglish()) {
LocaleOverlay overlay = IrisLanguage.loadBundledOverlay(locale);
LocaleOverlay overlay = loadSourceOverlay(locale);
TextValue irisName = (TextValue) overlay.value(
BukkitCommandMessagesExtended.COMMAND_IRIS_YOU_CANNOT_USE_WORLD_NAME_IRIS_CREATING_WORLDS_AS_IRIS.id()
);
@@ -224,7 +262,7 @@ public class IrisLanguageTest {
}
@Test
public void bundledLocaleLoadsWithoutUserOverride() {
public void downloadedLocaleLoadsWithoutUserOverride() {
assertTrue(IrisLanguage.reload(dataFolder, "de_DE"));
assertEquals("de_DE", IrisLanguage.activeLocale());
assertFalse("Unknown Iris command".equals(IrisLanguage.plain(IrisMessages.COMMAND_UNKNOWN)));