Server-side translations for items and inventory titles

This commit is contained in:
NichtStudioCode 2023-02-25 18:18:44 +01:00
parent b79012cdd7
commit b86f79d851
13 changed files with 406 additions and 63 deletions

@ -3,6 +3,8 @@ package xyz.xenondevs.inventoryaccess.component;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import xyz.xenondevs.inventoryaccess.component.i18n.AdventureComponentLocalizer;
import xyz.xenondevs.inventoryaccess.component.i18n.Languages;
public class AdventureComponentWrapper implements ComponentWrapper { public class AdventureComponentWrapper implements ComponentWrapper {
@ -18,9 +20,17 @@ public class AdventureComponentWrapper implements ComponentWrapper {
} }
@Override @Override
public @NotNull ComponentWrapper clone() { public @NotNull AdventureComponentWrapper localized(@NotNull String lang) {
if (!Languages.getInstance().doesServerSideTranslations())
return this;
return new AdventureComponentWrapper(AdventureComponentLocalizer.getInstance().localize(lang, component));
}
@Override
public @NotNull AdventureComponentWrapper clone() {
try { try {
return (ComponentWrapper) super.clone(); return (AdventureComponentWrapper) super.clone();
} catch (CloneNotSupportedException e) { } catch (CloneNotSupportedException e) {
throw new AssertionError(); throw new AssertionError();
} }

@ -3,6 +3,8 @@ package xyz.xenondevs.inventoryaccess.component;
import net.md_5.bungee.api.chat.BaseComponent; import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.chat.ComponentSerializer; import net.md_5.bungee.chat.ComponentSerializer;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import xyz.xenondevs.inventoryaccess.component.i18n.BaseComponentLocalizer;
import xyz.xenondevs.inventoryaccess.component.i18n.Languages;
public class BaseComponentWrapper implements ComponentWrapper { public class BaseComponentWrapper implements ComponentWrapper {
@ -12,6 +14,14 @@ public class BaseComponentWrapper implements ComponentWrapper {
this.components = components; this.components = components;
} }
@Override
public @NotNull ComponentWrapper localized(@NotNull String lang) {
if (!Languages.getInstance().doesServerSideTranslations())
return this;
return new BaseComponentWrapper(BaseComponentLocalizer.getInstance().localize(lang, components));
}
@Override @Override
public @NotNull String serializeToJson() { public @NotNull String serializeToJson() {
return ComponentSerializer.toString(components); return ComponentSerializer.toString(components);

@ -1,11 +1,34 @@
package xyz.xenondevs.inventoryaccess.component; package xyz.xenondevs.inventoryaccess.component;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import xyz.xenondevs.inventoryaccess.component.i18n.Languages;
public interface ComponentWrapper extends Cloneable { public interface ComponentWrapper extends Cloneable {
/**
* Serializes the component to a json string.
*
* @return The json representation of the component.
*/
@NotNull String serializeToJson(); @NotNull String serializeToJson();
/**
* Creates a localized version of the component by replacing all translatable components with text components
* of the specified language.
* <p>
* This method will return the same {@link ComponentWrapper} when {@link Languages} is disabled.
*
* @param lang The language to use.
* @return A new {@link ComponentWrapper} of the localized component or the same {@link ComponentWrapper}
* if {@link Languages} is disabled.
*/
@NotNull ComponentWrapper localized(@NotNull String lang);
/**
* Clones this {@link ComponentWrapper}.
*
* @return The cloned {@link ComponentWrapper}.
*/
@NotNull ComponentWrapper clone(); @NotNull ComponentWrapper clone();
} }

@ -0,0 +1,57 @@
package xyz.xenondevs.inventoryaccess.component.i18n;
import net.kyori.adventure.text.*;
public class AdventureComponentLocalizer extends ComponentLocalizer<Component> {
public static final AdventureComponentLocalizer INSTANCE = new AdventureComponentLocalizer();
private AdventureComponentLocalizer() {
}
public static AdventureComponentLocalizer getInstance() {
return INSTANCE;
}
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public Component localize(String lang, Component component) {
if (!(component instanceof BuildableComponent))
throw new IllegalStateException("Component is not a BuildableComponent");
return localize(lang, (BuildableComponent) component);
}
@SuppressWarnings("NonExtendableApiUsage")
private <C extends BuildableComponent<C, B>, B extends ComponentBuilder<C, B>> BuildableComponent<?, ?> localize(String lang, BuildableComponent<C, B> component) {
ComponentBuilder<?, ?> builder;
if (component instanceof TranslatableComponent) {
builder = localizeTranslatable(lang, (TranslatableComponent) component).toBuilder();
} else {
builder = component.toBuilder();
}
builder.mapChildrenDeep(child -> {
if (child instanceof TranslatableComponent)
return localizeTranslatable(lang, (TranslatableComponent) child);
return child;
});
return builder.build();
}
private BuildableComponent<?, ?> localizeTranslatable(String lang, TranslatableComponent component) {
var formatString = Languages.getInstance().getFormatString(lang, component.key());
if (formatString == null)
return component;
var children = decomposeFormatString(lang, formatString, component, component.args());
return Component.textOfChildren(children.toArray(ComponentLike[]::new)).style(component.style());
}
@Override
protected Component createTextComponent(String text) {
return Component.text(text);
}
}

@ -0,0 +1,71 @@
package xyz.xenondevs.inventoryaccess.component.i18n;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.chat.TranslatableComponent;
import java.util.stream.Collectors;
public class BaseComponentLocalizer extends ComponentLocalizer<BaseComponent> {
private static final BaseComponentLocalizer INSTANCE = new BaseComponentLocalizer();
private BaseComponentLocalizer() {
}
public static BaseComponentLocalizer getInstance() {
return INSTANCE;
}
public BaseComponent[] localize(String lang, BaseComponent[] components) {
var localizedComponents = new BaseComponent[components.length];
for (int i = 0; i < components.length; i++) {
localizedComponents[i] = localize(lang, components[i]);
}
return localizedComponents;
}
@Override
public BaseComponent localize(String lang, BaseComponent component) {
BaseComponent duplicate;
if (component instanceof TranslatableComponent) {
duplicate = localizeTranslatable(lang, (TranslatableComponent) component);
} else {
duplicate = component.duplicate();
}
var extra = duplicate.getExtra();
if (extra != null) {
duplicate.setExtra(
extra.stream()
.map(child -> localize(lang, child))
.collect(Collectors.toList())
);
}
return duplicate;
}
private BaseComponent localizeTranslatable(String lang, TranslatableComponent component) {
var formatString = Languages.getInstance().getFormatString(lang, component.getTranslate());
if (formatString == null)
return component;
var children = decomposeFormatString(lang, formatString, component, component.getWith());
var result = new TextComponent(children.toArray(BaseComponent[]::new));
result.copyFormatting(component);
var extra = component.getExtra();
if (extra != null)
result.setExtra(extra);
return result;
}
@Override
protected BaseComponent createTextComponent(String text) {
return new TextComponent(text);
}
}

@ -0,0 +1,69 @@
package xyz.xenondevs.inventoryaccess.component.i18n;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
abstract class ComponentLocalizer<C> {
private static final Pattern FORMAT_PATTERN = Pattern.compile("%(?:(\\d+)\\$)?([A-Za-z%]|$)");
public abstract C localize(String lang, C component);
protected abstract C createTextComponent(String text);
protected List<C> decomposeFormatString(String lang, String formatString, C component, List<C> args) {
var matcher = FORMAT_PATTERN.matcher(formatString);
var components = new ArrayList<C>();
var sb = new StringBuilder();
var nextArgIdx = 0;
var i = 0;
while (matcher.find(i)) {
var start = matcher.start();
var end = matcher.end();
// check for escaped %
var matchedStr = formatString.substring(i, start);
if ("%%".equals(matchedStr)) {
sb.append('%');
} else {
// check for invalid format, only %s is supported
var argType = matcher.group(2);
if (!"s".equals(argType)) {
throw new IllegalStateException("Unsupported format: '" + matchedStr + "'");
}
// retrieve argument index
var argIdxStr = matcher.group(1);
var argIdx = argIdxStr == null ? nextArgIdx++ : Integer.parseInt(argIdxStr) - 1;
// validate argument index
if (argIdx < 0)
throw new IllegalStateException("Invalid argument index: " + argIdx);
// append the text before the argument
sb.append(formatString, i, start);
// add text component
components.add(createTextComponent(sb.toString()));
// add argument component
components.add(args.size() <= argIdx ? createTextComponent("") : localize(lang, args.get(argIdx)));
// clear string builder
sb.setLength(0);
}
// start next search after matcher end index
i = end;
}
// append the text after the last argument
if (i < formatString.length()) {
sb.append(formatString, i, formatString.length());
components.add(createTextComponent(sb.toString()));
}
return components;
}
}

@ -0,0 +1,96 @@
package xyz.xenondevs.inventoryaccess.component.i18n;
import com.google.gson.stream.JsonReader;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.xenondevs.inventoryaccess.component.ComponentWrapper;
import java.io.IOException;
import java.io.Reader;
import java.util.HashMap;
import java.util.Map;
public class Languages {
private static final Languages INSTANCE = new Languages();
private final Map<String, Map<String, String>> translations = new HashMap<>();
private boolean serverSideTranslations = true;
private Languages() {
}
public static @NotNull Languages getInstance() {
return INSTANCE;
}
/**
* Adds a language under the given lang code.
* <p>
* This method will replace any existing language with the same lang code.
*
* @param lang The lang code of the language.
* @param translations The translations of the language.
*/
public void addLanguage(@NotNull String lang, @NotNull Map<@NotNull String, @NotNull String> translations) {
this.translations.put(lang, translations);
}
/**
* Adds a language under the given lang code after reading it from the given reader.
* <p>
* Note: The language is read as a json object with the translation keys as keys and the format strings as
* their string values. Any other json structure will result in an {@link IllegalStateException}.
* An example for such a structure are Minecraft's lang files.
*
* @param lang The lang code of the language.
* @param reader The reader for a language json file.
* @throws IOException If an error occurs while reading.
* @throws IllegalStateException If the json is not valid.
*/
public void loadLanguage(@NotNull String lang, @NotNull Reader reader) throws IOException {
var translations = new HashMap<String, String>();
try (var jsonReader = new JsonReader(reader)) {
jsonReader.beginObject();
while (jsonReader.hasNext()) {
var key = jsonReader.nextName();
var value = jsonReader.nextString();
translations.put(key, value);
}
addLanguage(lang, translations);
}
}
/**
* Retrieves the format string for the given key under the given language.
*
* @param lang The language to use.
* @param key The key of the format string.
* @return The format string or null if there is no such language or key.
*/
public @Nullable String getFormatString(@NotNull String lang, @NotNull String key) {
var map = translations.get(lang);
if (map == null)
return null;
return map.get(key);
}
/**
* Enables or disables server-side translations for {@link ComponentWrapper ComponentWrappers}.
*
* @param enable Whether server-side translations should be enabled.
*/
public void enableServerSideTranslations(boolean enable) {
serverSideTranslations = enable;
}
/**
* Checks whether server-side translations are enabled.
*
* @return Whether server-side translations are enabled.
*/
public boolean doesServerSideTranslations() {
return serverSideTranslations;
}
}

@ -124,13 +124,13 @@ public abstract class AbstractGui implements Gui, GuiParent {
} }
private boolean didClickBackgroundItem(Player player, SlotElement.VISlotElement element, VirtualInventory inventory, int slot, ItemStack clicked) { private boolean didClickBackgroundItem(Player player, SlotElement.VISlotElement element, VirtualInventory inventory, int slot, ItemStack clicked) {
UUID uuid = player.getUniqueId(); String lang = player.getLocale();
return inventory.getUnsafeItemStack(slot) == null return inventory.getUnsafeItemStack(slot) == null
&& (isBuilderSimilar(background, uuid, clicked) || isBuilderSimilar(element.getBackground(), uuid, clicked)); && (isBuilderSimilar(background, lang, clicked) || isBuilderSimilar(element.getBackground(), lang, clicked));
} }
private boolean isBuilderSimilar(ItemProvider builder, UUID uuid, ItemStack expected) { private boolean isBuilderSimilar(ItemProvider builder, String lang, ItemStack expected) {
return builder != null && builder.getFor(uuid).isSimilar(expected); return builder != null && builder.get(lang).isSimilar(expected);
} }
@SuppressWarnings("deprecation") @SuppressWarnings("deprecation")

@ -7,11 +7,10 @@ import xyz.xenondevs.invui.virtualinventory.VirtualInventory;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID;
public interface SlotElement { public interface SlotElement {
ItemStack getItemStack(UUID viewerUUID); ItemStack getItemStack(String lang);
SlotElement getHoldingElement(); SlotElement getHoldingElement();
@ -31,8 +30,8 @@ public interface SlotElement {
} }
@Override @Override
public ItemStack getItemStack(UUID viewerUUID) { public ItemStack getItemStack(String lang) {
return item.getItemProvider().getFor(viewerUUID); return item.getItemProvider().get(lang);
} }
@Override @Override
@ -76,9 +75,9 @@ public interface SlotElement {
} }
@Override @Override
public ItemStack getItemStack(UUID viewerUUID) { public ItemStack getItemStack(String lang) {
ItemStack itemStack = virtualInventory.getUnsafeItemStack(slot); ItemStack itemStack = virtualInventory.getUnsafeItemStack(slot);
if (itemStack == null && background != null) itemStack = background.getFor(viewerUUID); if (itemStack == null && background != null) itemStack = background.get(lang);
return itemStack; return itemStack;
} }
@ -139,9 +138,9 @@ public interface SlotElement {
} }
@Override @Override
public ItemStack getItemStack(UUID viewerUUID) { public ItemStack getItemStack(String lang) {
SlotElement holdingElement = getHoldingElement(); SlotElement holdingElement = getHoldingElement();
return holdingElement != null ? holdingElement.getItemStack(viewerUUID) : null; return holdingElement != null ? holdingElement.getItemStack(lang) : null;
} }
} }

@ -1,35 +1,33 @@
package xyz.xenondevs.invui.item; package xyz.xenondevs.invui.item;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable;
import xyz.xenondevs.invui.window.AbstractWindow;
import java.util.UUID;
import java.util.function.Supplier; import java.util.function.Supplier;
public interface ItemProvider extends Supplier<ItemStack>, Cloneable { public interface ItemProvider extends Supplier<ItemStack>, Cloneable {
/**
* An {@link ItemProvider} for an {@link ItemStack}.
*/
ItemProvider EMPTY = new ItemWrapper(new ItemStack(Material.AIR)); ItemProvider EMPTY = new ItemWrapper(new ItemStack(Material.AIR));
/** /**
* Builds the {@link ItemStack} * Gets the {@link ItemStack} translated in the specified language.
* *
* @param lang The language to translate the item in.
* @return The {@link ItemStack} * @return The {@link ItemStack}
*/ */
ItemStack get(); ItemStack get(@Nullable String lang);
/** /**
* Gets the {@link ItemStack} for a specific player. * Gets the {@link ItemStack} without requesting a specific language.
* This is the method called by {@link AbstractWindow} which gives you
* the option to (for example) create a subclass of {@link ItemProvider} that automatically
* translates the item's name into the player's language.
* *
* @param playerUUID The {@link UUID} of the {@link Player}
* for whom this {@link ItemStack} should be made.
* @return The {@link ItemStack} * @return The {@link ItemStack}
*/ */
ItemStack getFor(@NotNull UUID playerUUID); default ItemStack get() {
return get(null);
}
} }

@ -1,7 +1,6 @@
package xyz.xenondevs.invui.item; package xyz.xenondevs.invui.item;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.UUID; import java.util.UUID;
@ -18,12 +17,7 @@ public class ItemWrapper implements ItemProvider {
} }
@Override @Override
public ItemStack get() { public ItemStack get(String lang) {
return itemStack;
}
@Override
public ItemStack getFor(@NotNull UUID playerUUID) {
return itemStack; return itemStack;
} }

@ -3,21 +3,23 @@ package xyz.xenondevs.invui.item.builder;
import net.md_5.bungee.api.chat.BaseComponent; import net.md_5.bungee.api.chat.BaseComponent;
import org.bukkit.Material; import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment; import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemFlag;
import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.Damageable;
import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.ItemMeta;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import xyz.xenondevs.inventoryaccess.InventoryAccess; import xyz.xenondevs.inventoryaccess.InventoryAccess;
import xyz.xenondevs.inventoryaccess.component.BaseComponentWrapper; import xyz.xenondevs.inventoryaccess.component.BaseComponentWrapper;
import xyz.xenondevs.inventoryaccess.component.ComponentWrapper; import xyz.xenondevs.inventoryaccess.component.ComponentWrapper;
import xyz.xenondevs.invui.item.ItemProvider; import xyz.xenondevs.invui.item.ItemProvider;
import xyz.xenondevs.invui.util.ComponentUtils; import xyz.xenondevs.invui.util.ComponentUtils;
import xyz.xenondevs.invui.util.Pair; import xyz.xenondevs.invui.util.Pair;
import xyz.xenondevs.invui.window.AbstractWindow;
import java.util.*; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -71,7 +73,7 @@ public abstract class AbstractItemBuilder<T> implements ItemProvider {
* @return The {@link ItemStack} * @return The {@link ItemStack}
*/ */
@Override @Override
public ItemStack get() { public ItemStack get(@Nullable String lang) {
ItemStack itemStack; ItemStack itemStack;
if (base != null) { if (base != null) {
itemStack = base; itemStack = base;
@ -83,12 +85,25 @@ public abstract class AbstractItemBuilder<T> implements ItemProvider {
ItemMeta itemMeta = itemStack.getItemMeta(); ItemMeta itemMeta = itemStack.getItemMeta();
if (itemMeta != null) { if (itemMeta != null) {
// display name // display name
if (displayName != null) if (displayName != null) {
InventoryAccess.getItemUtils().setDisplayName(itemMeta, displayName); InventoryAccess.getItemUtils().setDisplayName(
itemMeta,
(lang != null) ? displayName.localized(lang) : displayName
);
}
// lore // lore
if (lore != null) if (lore != null) {
if (lang != null) {
var translatedLore = lore.stream()
.map(wrapper -> wrapper.localized(lang))
.collect(Collectors.toList());
InventoryAccess.getItemUtils().setLore(itemMeta, translatedLore);
} else {
InventoryAccess.getItemUtils().setLore(itemMeta, lore); InventoryAccess.getItemUtils().setLore(itemMeta, lore);
}
}
// damage // damage
if (itemMeta instanceof Damageable) if (itemMeta instanceof Damageable)
@ -127,21 +142,6 @@ public abstract class AbstractItemBuilder<T> implements ItemProvider {
return itemStack; return itemStack;
} }
/**
* Builds the {@link ItemStack} for a specific player.
* This is the method called by {@link AbstractWindow} which gives you
* the option to (for example) create a subclass of {@link AbstractItemBuilder} that automatically
* translates the item's name into the player's language.
*
* @param playerUUID The {@link UUID} of the {@link Player}
* for whom this {@link ItemStack} should be built.
* @return The {@link ItemStack}
*/
@Override
public ItemStack getFor(@NotNull UUID playerUUID) {
return get();
}
public T removeLoreLine(int index) { public T removeLoreLine(int index) {
if (lore != null) lore.remove(index); if (lore != null) lore.remove(index);
return getThis(); return getThis();

@ -66,9 +66,9 @@ public abstract class AbstractWindow implements Window, GuiParent {
protected void redrawItem(int index, SlotElement element, boolean setItem) { protected void redrawItem(int index, SlotElement element, boolean setItem) {
// put ItemStack in inventory // put ItemStack in inventory
ItemStack itemStack; ItemStack itemStack;
if (element == null || (element instanceof SlotElement.VISlotElement && element.getItemStack(viewerUUID) == null)) { if (element == null || (element instanceof SlotElement.VISlotElement && element.getItemStack(getLang()) == null)) {
ItemProvider background = getGuiAt(index).getFirst().getBackground(); ItemProvider background = getGuiAt(index).getFirst().getBackground();
itemStack = background == null ? null : background.getFor(viewerUUID); itemStack = background == null ? null : background.get(getLang());
} else if (element instanceof SlotElement.LinkedSlotElement && element.getHoldingElement() == null) { } else if (element instanceof SlotElement.LinkedSlotElement && element.getHoldingElement() == null) {
ItemProvider background = null; ItemProvider background = null;
@ -80,9 +80,9 @@ public abstract class AbstractWindow implements Window, GuiParent {
if (background != null) break; if (background != null) break;
} }
itemStack = background == null ? null : background.getFor(viewerUUID); itemStack = background == null ? null : background.get(getLang());
} else { } else {
itemStack = element.getItemStack(viewerUUID); itemStack = element.getItemStack(getLang());
// This makes every item unique to prevent Shift-DoubleClick "clicking" multiple items at the same time. // This makes every item unique to prevent Shift-DoubleClick "clicking" multiple items at the same time.
if (itemStack.hasItemMeta()) { if (itemStack.hasItemMeta()) {
@ -255,7 +255,11 @@ public abstract class AbstractWindow implements Window, GuiParent {
Player viewer = getViewer(); Player viewer = getViewer();
if (viewer == null) throw new IllegalStateException("The player is not online."); if (viewer == null) throw new IllegalStateException("The player is not online.");
InventoryAccess.getInventoryUtils().openCustomInventory(viewer, getInventories()[0], title); InventoryAccess.getInventoryUtils().openCustomInventory(
viewer,
getInventories()[0],
title.localized(viewer.getLocale())
);
} }
@Override @Override
@ -263,7 +267,10 @@ public abstract class AbstractWindow implements Window, GuiParent {
this.title = title; this.title = title;
Player currentViewer = getCurrentViewer(); Player currentViewer = getCurrentViewer();
if (currentViewer != null) { if (currentViewer != null) {
InventoryAccess.getInventoryUtils().updateOpenInventoryTitle(currentViewer, title); InventoryAccess.getInventoryUtils().updateOpenInventoryTitle(
currentViewer,
title.localized(currentViewer.getLocale())
);
} }
} }
@ -307,6 +314,15 @@ public abstract class AbstractWindow implements Window, GuiParent {
return Bukkit.getPlayer(viewerUUID); return Bukkit.getPlayer(viewerUUID);
} }
// TODO: could also return null / "en_us"
public @NotNull String getLang() {
var player = getViewer();
if (player == null)
throw new IllegalStateException("Tried to receive the language from a viewer that is not online.");
return player.getLocale();
}
@Override @Override
public @NotNull UUID getViewerUUID() { public @NotNull UUID getViewerUUID() {
return viewerUUID; return viewerUUID;