feat: hover translation (Google, Chrome endpoint with gtx fallback), LuckPerms prefixes in chat, restyled messages
Some checks are pending
Build & Test / Build LoChat (push) Waiting to run
Some checks are pending
Build & Test / Build LoChat (push) Waiting to run
This commit is contained in:
parent
bd39118b14
commit
260986ff11
9 changed files with 396 additions and 81 deletions
|
|
@ -60,6 +60,8 @@ public final class LoChat extends JavaPlugin {
|
||||||
configManager.getTranslationEndpoint(),
|
configManager.getTranslationEndpoint(),
|
||||||
configManager.getTranslationApiKey(),
|
configManager.getTranslationApiKey(),
|
||||||
configManager.isTranslationEnabled(),
|
configManager.isTranslationEnabled(),
|
||||||
|
configManager.getTranslationProvider(),
|
||||||
|
configManager.getTranslationTimeoutMs(),
|
||||||
getLogger()
|
getLogger()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,26 @@ public class ConfigManager {
|
||||||
return accessor.getTranslationApiKey();
|
return accessor.getTranslationApiKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getTranslationProvider() {
|
||||||
|
return accessor.getTranslationProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTranslationMode() {
|
||||||
|
return accessor.getTranslationMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTranslationTimeoutMs() {
|
||||||
|
return accessor.getTranslationTimeoutMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> getTranslationHoverFormat() {
|
||||||
|
return accessor.getTranslationHoverFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTranslationBypassPermission() {
|
||||||
|
return accessor.getTranslationBypassPermission();
|
||||||
|
}
|
||||||
|
|
||||||
// ========== LoPreff integration ==========
|
// ========== LoPreff integration ==========
|
||||||
|
|
||||||
public boolean isLopreffEnabled() {
|
public boolean isLopreffEnabled() {
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,29 @@ public class ConfigAccessor {
|
||||||
return config.getString("chat.translation.api-key", "");
|
return config.getString("chat.translation.api-key", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "libretranslate" (needs endpoint and usually a key) or "google" (free, no key). */
|
||||||
|
public String getTranslationProvider() {
|
||||||
|
return config.getString("chat.translation.provider", "libretranslate");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "hover" shows the translation when hovering the message, "actionbar" shows it above the hotbar. */
|
||||||
|
public String getTranslationMode() {
|
||||||
|
return config.getString("chat.translation.mode", "hover");
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTranslationTimeoutMs() {
|
||||||
|
return config.getInt("chat.translation.timeout-ms", 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public java.util.List<String> getTranslationHoverFormat() {
|
||||||
|
java.util.List<String> lines = config.getStringList("chat.translation.hover-format");
|
||||||
|
return lines.isEmpty() ? java.util.List.of("&7Translation:", "&f{translation}") : lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTranslationBypassPermission() {
|
||||||
|
return config.getString("chat.translation.bypass-permission", "lochat.translate.bypass");
|
||||||
|
}
|
||||||
|
|
||||||
// ========== LoPreff integration ==========
|
// ========== LoPreff integration ==========
|
||||||
|
|
||||||
public boolean isLopreffEnabled() {
|
public boolean isLopreffEnabled() {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import com.loki.lochat.compat.Msg;
|
||||||
import com.loki.lochat.core.filter.AdvancedMessageFilter;
|
import com.loki.lochat.core.filter.AdvancedMessageFilter;
|
||||||
import com.loki.lochat.core.filter.FilterResult;
|
import com.loki.lochat.core.filter.FilterResult;
|
||||||
import com.loki.lochat.renderer.EnhancedChatRenderer;
|
import com.loki.lochat.renderer.EnhancedChatRenderer;
|
||||||
|
import com.loki.lochat.translate.TranslationService;
|
||||||
import com.loki.lochat.utils.format.ChatFormatter;
|
import com.loki.lochat.utils.format.ChatFormatter;
|
||||||
|
import com.loki.lochat.utils.format.LuckPermsAffixes;
|
||||||
|
|
||||||
import net.kyori.adventure.text.Component;
|
import net.kyori.adventure.text.Component;
|
||||||
|
|
||||||
|
|
@ -80,6 +82,7 @@ public class ChatEventListener implements Listener {
|
||||||
Component displayName = buildDisplayName(sender);
|
Component displayName = buildDisplayName(sender);
|
||||||
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
|
prefetchTranslation(sender, messageComponent, recipients);
|
||||||
for (Player viewer : recipients) {
|
for (Player viewer : recipients) {
|
||||||
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
||||||
Msg.send(viewer, rendered);
|
Msg.send(viewer, rendered);
|
||||||
|
|
@ -132,6 +135,7 @@ public class ChatEventListener implements Listener {
|
||||||
Component displayName = buildDisplayName(sender);
|
Component displayName = buildDisplayName(sender);
|
||||||
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
Set<Player> recipients = new HashSet<>(event.getRecipients());
|
||||||
event.setCancelled(true);
|
event.setCancelled(true);
|
||||||
|
prefetchTranslation(sender, messageComponent, recipients);
|
||||||
for (Player viewer : recipients) {
|
for (Player viewer : recipients) {
|
||||||
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
Component rendered = renderer.render(sender, displayName, messageComponent, viewer);
|
||||||
Msg.send(viewer, rendered);
|
Msg.send(viewer, rendered);
|
||||||
|
|
@ -226,9 +230,37 @@ public class ChatEventListener implements Listener {
|
||||||
if (legacyName == null || legacyName.isBlank()) {
|
if (legacyName == null || legacyName.isBlank()) {
|
||||||
return Component.text(sender.getName());
|
return Component.text(sender.getName());
|
||||||
}
|
}
|
||||||
|
// LuckPerms prefix/suffix are added separately ({player_prefix}/{player_suffix}), do not show them twice
|
||||||
|
legacyName = LuckPermsAffixes.withoutAffixes(legacyName, LuckPermsAffixes.prefix(sender),
|
||||||
|
LuckPermsAffixes.suffix(sender));
|
||||||
return ChatFormatter.parse(legacyName);
|
return ChatFormatter.parse(legacyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Translates the message for every viewer language before rendering (runs off the main thread only). */
|
||||||
|
private void prefetchTranslation(Player sender, Component messageComponent, Set<Player> recipients) {
|
||||||
|
try {
|
||||||
|
LoChat loChat = (LoChat) plugin;
|
||||||
|
var cfg = loChat.getConfigManager();
|
||||||
|
TranslationService ts = loChat.getTranslationService();
|
||||||
|
if (!cfg.isTranslationEnabled() || !ts.isEnabled() || Bukkit.isPrimaryThread()
|
||||||
|
|| "actionbar".equalsIgnoreCase(cfg.getTranslationMode())
|
||||||
|
|| sender.hasPermission(cfg.getTranslationBypassPermission())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Set<String> languages = new HashSet<>();
|
||||||
|
for (Player viewer : recipients) {
|
||||||
|
if (!viewer.equals(sender)) {
|
||||||
|
languages.add(TranslationService.localeToLanguageCode(viewer.getLocale()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!languages.isEmpty()) {
|
||||||
|
ts.prefetch(Msg.plain(messageComponent), languages, cfg.getTranslationTimeoutMs());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
plugin.getLogger().fine("Translation prefetch failed: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void recordStatistics(LoChat loChat, Player sender, boolean isGlobal) {
|
private void recordStatistics(LoChat loChat, Player sender, boolean isGlobal) {
|
||||||
playerService.recordMessage(sender.getUniqueId(), isGlobal ? "global" : "local");
|
playerService.recordMessage(sender.getUniqueId(), isGlobal ? "global" : "local");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,7 @@ public class EnhancedChatRenderer {
|
||||||
|
|
||||||
if (viewer != null) {
|
if (viewer != null) {
|
||||||
processed = mentionHandler.processMentions(processed, source, viewer);
|
processed = mentionHandler.processMentions(processed, source, viewer);
|
||||||
if (cfg.isTranslationEnabled()) {
|
processed = applyTranslation(source, viewer, message, processed);
|
||||||
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
|
|
||||||
if (loChat.getTranslationService().isEnabled()) {
|
|
||||||
sendTranslationAsync(viewer, PlainTextComponentSerializer.plainText().serialize(processed));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Получаем формат из конфига
|
// Получаем формат из конфига
|
||||||
|
|
@ -124,7 +119,7 @@ public class EnhancedChatRenderer {
|
||||||
|
|
||||||
while (matcher.find()) {
|
while (matcher.find()) {
|
||||||
if (matcher.start() > lastEnd) {
|
if (matcher.start() > lastEnd) {
|
||||||
result = result.append(Component.text(format.substring(lastEnd, matcher.start())));
|
result = result.append(ChatFormatter.parse(format.substring(lastEnd, matcher.start())));
|
||||||
}
|
}
|
||||||
|
|
||||||
String placeholder = matcher.group(1);
|
String placeholder = matcher.group(1);
|
||||||
|
|
@ -134,7 +129,7 @@ public class EnhancedChatRenderer {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastEnd < format.length()) {
|
if (lastEnd < format.length()) {
|
||||||
result = result.append(Component.text(format.substring(lastEnd)));
|
result = result.append(ChatFormatter.parse(format.substring(lastEnd)));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -156,6 +151,11 @@ public class EnhancedChatRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String luckPermsPrefix = com.loki.lochat.utils.format.LuckPermsAffixes.prefix(player);
|
||||||
|
if (!luckPermsPrefix.isEmpty()) {
|
||||||
|
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
return Component.empty();
|
return Component.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -170,6 +170,11 @@ public class EnhancedChatRenderer {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String luckPermsSuffix = com.loki.lochat.utils.format.LuckPermsAffixes.suffix(player);
|
||||||
|
if (!luckPermsSuffix.isEmpty()) {
|
||||||
|
return com.loki.lochat.utils.format.ChatFormatter.parse(luckPermsSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
return Component.empty();
|
return Component.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -239,6 +244,50 @@ public class EnhancedChatRenderer {
|
||||||
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
|
.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " "));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the translation into the viewer's client language: as hover text over the message (default)
|
||||||
|
* or in the action bar. The translation must already be cached (see ChatEventListener#prefetchTranslation).
|
||||||
|
*/
|
||||||
|
private Component applyTranslation(Player source, Player viewer, Component original, Component processed) {
|
||||||
|
if (!cfg.isTranslationEnabled() || viewer.equals(source)) {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin;
|
||||||
|
TranslationService ts = loChat.getTranslationService();
|
||||||
|
if (!ts.isEnabled() || source.hasPermission(cfg.getTranslationBypassPermission())) {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
String plain = PlainTextComponentSerializer.plainText().serialize(original);
|
||||||
|
if (plain.isBlank()) {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
if ("actionbar".equalsIgnoreCase(cfg.getTranslationMode())) {
|
||||||
|
sendTranslationAsync(viewer, plain);
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
String translated = ts.getCachedTranslation(plain, viewer.getLocale());
|
||||||
|
if (translated == null || translated.isBlank() || translated.equalsIgnoreCase(plain)) {
|
||||||
|
return processed;
|
||||||
|
}
|
||||||
|
Component hover = Component.empty();
|
||||||
|
boolean first = true;
|
||||||
|
for (String line : cfg.getTranslationHoverFormat()) {
|
||||||
|
if (!first) {
|
||||||
|
hover = hover.append(Component.newline());
|
||||||
|
}
|
||||||
|
first = false;
|
||||||
|
int idx = line.indexOf("{translation}");
|
||||||
|
if (idx < 0) {
|
||||||
|
hover = hover.append(ChatFormatter.parse(line));
|
||||||
|
} else {
|
||||||
|
hover = hover.append(ChatFormatter.parse(line.substring(0, idx)))
|
||||||
|
.append(Component.text(translated))
|
||||||
|
.append(ChatFormatter.parse(line.substring(idx + "{translation}".length())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return processed.hoverEvent(HoverEvent.showText(hover));
|
||||||
|
}
|
||||||
|
|
||||||
private void sendTranslationAsync(Player viewer, String plainText) {
|
private void sendTranslationAsync(Player viewer, String plainText) {
|
||||||
if (plainText.isBlank()) {
|
if (plainText.isBlank()) {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,20 @@ public class TranslationService {
|
||||||
private final String endpoint;
|
private final String endpoint;
|
||||||
private final String apiKey;
|
private final String apiKey;
|
||||||
private final boolean enabled;
|
private final boolean enabled;
|
||||||
|
private final String provider;
|
||||||
|
private final int timeoutMs;
|
||||||
|
|
||||||
public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) {
|
public TranslationService(String endpoint, String apiKey, boolean enabled, Logger logger) {
|
||||||
|
this(endpoint, apiKey, enabled, "libretranslate", 3000, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TranslationService(String endpoint, String apiKey, boolean enabled, String provider, int timeoutMs,
|
||||||
|
Logger logger) {
|
||||||
this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : "";
|
this.endpoint = endpoint != null ? endpoint.replaceAll("/+$", "") : "";
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
this.enabled = enabled;
|
this.enabled = enabled;
|
||||||
|
this.provider = provider == null ? "libretranslate" : provider.toLowerCase(Locale.ROOT);
|
||||||
|
this.timeoutMs = Math.max(500, timeoutMs);
|
||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
this.httpClient = HttpClient.newBuilder()
|
this.httpClient = HttpClient.newBuilder()
|
||||||
.connectTimeout(TIMEOUT)
|
.connectTimeout(TIMEOUT)
|
||||||
|
|
@ -59,8 +68,37 @@ public class TranslationService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isGoogle() {
|
||||||
|
return "google".equals(provider);
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isEnabled() {
|
public boolean isEnabled() {
|
||||||
return enabled && !endpoint.isBlank();
|
return enabled && (isGoogle() || !endpoint.isBlank());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached translation for the given language, or null if it has not been translated yet. */
|
||||||
|
public String getCachedTranslation(String text, String language) {
|
||||||
|
return getCached(text, localeToLanguageCode(language));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translates the text into every given language in parallel and waits up to timeoutMs.
|
||||||
|
* Call it off the main thread; the results end up in the cache.
|
||||||
|
*/
|
||||||
|
public void prefetch(String text, java.util.Collection<String> languages, long timeoutMs) {
|
||||||
|
if (!isEnabled() || text == null || text.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.util.List<CompletableFuture<String>> futures = new java.util.ArrayList<>();
|
||||||
|
for (String language : languages) {
|
||||||
|
futures.add(translateAsync(text, language));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
|
||||||
|
.get(timeoutMs, java.util.concurrent.TimeUnit.MILLISECONDS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// timeout or failure: the message is simply sent without a translation
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompletableFuture<String> translateAsync(String text, String targetLanguage) {
|
public CompletableFuture<String> translateAsync(String text, String targetLanguage) {
|
||||||
|
|
@ -89,7 +127,91 @@ public class TranslationService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private volatile long gtxBlockedUntil;
|
||||||
|
private volatile long chromeBlockedUntil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The gtx endpoint gets IP-blocked (HTTP 429 / captcha page) very quickly, so the Chrome dictionary
|
||||||
|
* endpoint is tried first and gtx is only a fallback; a blocked endpoint is skipped for a minute.
|
||||||
|
*/
|
||||||
|
private String doFetchGoogle(String text, String targetLang) throws Exception {
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
String q = java.net.URLEncoder.encode(text, StandardCharsets.UTF_8);
|
||||||
|
String tl = java.net.URLEncoder.encode(targetLang, StandardCharsets.UTF_8);
|
||||||
|
Exception last = null;
|
||||||
|
if (now >= chromeBlockedUntil) {
|
||||||
|
try {
|
||||||
|
return parseChromeResponse(get("https://clients5.google.com/translate_a/t?client=dict-chrome-ex&sl=auto&tl="
|
||||||
|
+ tl + "&q=" + q));
|
||||||
|
} catch (Exception e) {
|
||||||
|
chromeBlockedUntil = now + 60_000;
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (now >= gtxBlockedUntil) {
|
||||||
|
try {
|
||||||
|
return parseGoogleResponse(get("https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&dt=t&tl="
|
||||||
|
+ tl + "&q=" + q));
|
||||||
|
} catch (Exception e) {
|
||||||
|
gtxBlockedUntil = now + 60_000;
|
||||||
|
last = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw last != null ? last : new RuntimeException("all translation endpoints are on cooldown");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String get(String url) throws Exception {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(url))
|
||||||
|
.timeout(Duration.ofMillis(timeoutMs))
|
||||||
|
.header("User-Agent", "Mozilla/5.0")
|
||||||
|
.GET()
|
||||||
|
.build();
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new RuntimeException("Google returned " + response.statusCode());
|
||||||
|
}
|
||||||
|
return response.body();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chrome dictionary response: [["translated","detected-language"], ...]. */
|
||||||
|
static String parseChromeResponse(String body) {
|
||||||
|
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (com.google.gson.JsonElement segment : root) {
|
||||||
|
if (segment.isJsonArray()) {
|
||||||
|
com.google.gson.JsonArray parts = segment.getAsJsonArray();
|
||||||
|
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
|
||||||
|
sb.append(parts.get(0).getAsString());
|
||||||
|
}
|
||||||
|
} else if (segment.isJsonPrimitive()) {
|
||||||
|
sb.append(segment.getAsString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sb.length() == 0) {
|
||||||
|
throw new RuntimeException("empty translation response");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Google response: [[["translated","original",...], ...], null, "detected-language"]. */
|
||||||
|
static String parseGoogleResponse(String body) {
|
||||||
|
com.google.gson.JsonArray root = JsonParser.parseString(body).getAsJsonArray();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (com.google.gson.JsonElement segment : root.get(0).getAsJsonArray()) {
|
||||||
|
com.google.gson.JsonArray parts = segment.getAsJsonArray();
|
||||||
|
if (parts.size() > 0 && !parts.get(0).isJsonNull()) {
|
||||||
|
sb.append(parts.get(0).getAsString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
private String doFetch(String text, String targetLang) throws Exception {
|
private String doFetch(String text, String targetLang) throws Exception {
|
||||||
|
if (isGoogle()) {
|
||||||
|
return doFetchGoogle(text, targetLang);
|
||||||
|
}
|
||||||
JsonObject body = new JsonObject();
|
JsonObject body = new JsonObject();
|
||||||
body.addProperty("q", text);
|
body.addProperty("q", text);
|
||||||
body.addProperty("source", "auto");
|
body.addProperty("source", "auto");
|
||||||
|
|
@ -141,12 +263,12 @@ public class TranslationService {
|
||||||
if (locale == null || locale.isBlank()) {
|
if (locale == null || locale.isBlank()) {
|
||||||
return "en";
|
return "en";
|
||||||
}
|
}
|
||||||
String lang = locale.split("_")[0].toLowerCase(Locale.ROOT);
|
String normalized = locale.toLowerCase(Locale.ROOT).replace('-', '_');
|
||||||
return switch (lang) {
|
if (normalized.startsWith("zh_tw") || normalized.startsWith("zh_hk")) {
|
||||||
case "zh" -> "zh-CN";
|
return "zh-TW";
|
||||||
case "zh-tw", "zh-hk" -> "zh-TW";
|
}
|
||||||
default -> lang;
|
String lang = normalized.split("_")[0];
|
||||||
};
|
return "zh".equals(lang) ? "zh-CN" : lang;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void clearCache() {
|
public void clearCache() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.loki.lochat.utils.format;
|
||||||
|
|
||||||
|
import me.clip.placeholderapi.PlaceholderAPI;
|
||||||
|
|
||||||
|
import org.bukkit.Bukkit;
|
||||||
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LuckPerms prefix and suffix through PlaceholderAPI (%luckperms_prefix% / %luckperms_suffix%).
|
||||||
|
* Returns an empty string when PlaceholderAPI or its LuckPerms expansion is not available.
|
||||||
|
*/
|
||||||
|
public final class LuckPermsAffixes {
|
||||||
|
|
||||||
|
private static final Pattern COLOR = Pattern.compile("(?i)(&#[0-9a-f]{6}|[&\u00A7][0-9a-fk-orx])");
|
||||||
|
|
||||||
|
private LuckPermsAffixes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String prefix(Player player) {
|
||||||
|
return resolve(player, "%luckperms_prefix%");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String suffix(Player player) {
|
||||||
|
return resolve(player, "%luckperms_suffix%");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String resolve(Player player, String placeholder) {
|
||||||
|
if (player == null || !Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String result = PlaceholderAPI.setPlaceholders(player, placeholder);
|
||||||
|
if (result == null || result.contains("%")) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (Exception | LinkageError e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text without color codes. */
|
||||||
|
public static String strip(String text) {
|
||||||
|
return text == null ? "" : COLOR.matcher(text).replaceAll("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the LuckPerms prefix/suffix from a display name that already contains them
|
||||||
|
* (for example when another plugin put them into the nickname), so they are not shown twice.
|
||||||
|
* Returns the original text if nothing was removed.
|
||||||
|
*/
|
||||||
|
public static String withoutAffixes(String displayName, String prefix, String suffix) {
|
||||||
|
String plain = strip(displayName).trim();
|
||||||
|
String pp = strip(prefix).trim();
|
||||||
|
String sp = strip(suffix).trim();
|
||||||
|
String result = plain;
|
||||||
|
if (!pp.isEmpty() && result.startsWith(pp)) {
|
||||||
|
result = result.substring(pp.length()).trim();
|
||||||
|
}
|
||||||
|
if (!sp.isEmpty() && result.endsWith(sp)) {
|
||||||
|
result = result.substring(0, result.length() - sp.length()).trim();
|
||||||
|
}
|
||||||
|
return result.equals(plain) || result.isEmpty() ? displayName : result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,25 +5,25 @@
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
mute:
|
mute:
|
||||||
usage: "&cUsage: &f/mute <player> [time] [-s] [reason]"
|
usage: "&cUsage: &6/lmute <player> [time] [-s] [reason]"
|
||||||
time-help: "&7Time format: &f1d&7, &f2h&7, &f30m&7, &f60s&7, &fperm"
|
time-help: "&7Time format: &61d&7, &62h&7, &630m&7, &660s&7, &6perm"
|
||||||
silent-help: "&7-s &7= silent mute (requires &flochat.mute.silent&7)"
|
silent-help: "&7Use &6-s &7for a silent mute (needs &6lochat.mute.silent&7)"
|
||||||
no-silent-permission: "&cNo permission for silent mute"
|
no-silent-permission: "&cNo permission for silent mute"
|
||||||
default-reason: "No reason specified"
|
default-reason: "No reason specified"
|
||||||
default-duration: "7d"
|
default-duration: "7d"
|
||||||
|
|
||||||
cooldown:
|
cooldown:
|
||||||
message: "&cPlease wait &f{remaining} &cseconds"
|
message: "&cPlease wait &6{remaining} &cseconds"
|
||||||
|
|
||||||
clear-chat:
|
clear-chat:
|
||||||
lines-cleared: "&aChat cleared &7({lines} lines)"
|
lines-cleared: "&fChat cleared &7({lines} lines)"
|
||||||
no-permission: "&cNo permission to clear chat"
|
no-permission: "&cNo permission to clear chat"
|
||||||
|
|
||||||
# ── Local Chat ─────────────────────────────────────────────────────────────────
|
# ── Local Chat ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local-chat:
|
local-chat:
|
||||||
nobody-heard: "&7Nobody heard you — no players nearby"
|
nobody-heard: "&7Nobody heard you. Start your message with &6! &7to be heard everywhere."
|
||||||
no-players-nearby: "&7No players nearby"
|
no-players-nearby: "&7No players nearby. Start your message with &6! &7to write to everyone."
|
||||||
|
|
||||||
# ── Filters ────────────────────────────────────────────────────────────────────
|
# ── Filters ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -42,13 +42,13 @@ filters:
|
||||||
# ── Mute Messages ──────────────────────────────────────────────────────────────
|
# ── Mute Messages ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
mute:
|
mute:
|
||||||
muted-message: "&cYou are muted! &7Reason: &f{reason}&7. Remaining: &f{time}"
|
muted-message: "&cYou are muted! &7Reason: &6{reason}&7. Remaining: &6{time}"
|
||||||
muted-permanent: "&cYou are permanently muted! &7Reason: &f{reason}"
|
muted-permanent: "&cYou are permanently muted! &7Reason: &6{reason}"
|
||||||
player-muted: "&aPlayer &f{player} &amuted for &f{time}&a. Reason: &7{reason}"
|
player-muted: "&fPlayer &6{player} &fmuted for &6{time}&f. Reason: &7{reason}"
|
||||||
player-muted-permanent: "&aPlayer &f{player} &amuted permanently. Reason: &7{reason}"
|
player-muted-permanent: "&fPlayer &6{player} &fmuted permanently. Reason: &7{reason}"
|
||||||
player-muted-silent: "&8[&7SILENT&8] &f{player} &7muted for &f{time}"
|
player-muted-silent: "&8[&7SILENT&8] &6{player} &7muted for &6{time}"
|
||||||
player-unmuted: "&aPlayer &f{player} &aunmuted"
|
player-unmuted: "&fPlayer &6{player} &funmuted"
|
||||||
not-muted: "&7Player &f{player} &7is not muted"
|
not-muted: "&7Player &6{player} &7is not muted"
|
||||||
player-not-found: "&cPlayer not found!"
|
player-not-found: "&cPlayer not found!"
|
||||||
invalid-time: "&cInvalid time format!"
|
invalid-time: "&cInvalid time format!"
|
||||||
already-muted: "&cPlayer is already muted!"
|
already-muted: "&cPlayer is already muted!"
|
||||||
|
|
@ -66,17 +66,17 @@ gamemodes:
|
||||||
|
|
||||||
private-messages:
|
private-messages:
|
||||||
no-reply-target: "&cNobody to reply to"
|
no-reply-target: "&cNobody to reply to"
|
||||||
message-sent: "&aMessage sent to &f{player}"
|
message-sent: "&fMessage sent to &6{player}"
|
||||||
player-offline: "&cPlayer &f{player} &cis not online"
|
player-offline: "&cPlayer &6{player} &cis not online"
|
||||||
cannot-message-self: "&cYou cannot message yourself"
|
cannot-message-self: "&cYou cannot message yourself"
|
||||||
|
|
||||||
# ── Ignore ─────────────────────────────────────────────────────────────────────
|
# ── Ignore ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ignore:
|
ignore:
|
||||||
player-ignored: "&aPlayer &f{player} &ais now ignored"
|
player-ignored: "&fPlayer &6{player} &fis now ignored"
|
||||||
player-unignored: "&aPlayer &f{player} &ais no longer ignored"
|
player-unignored: "&fPlayer &6{player} &fis no longer ignored"
|
||||||
already-ignored: "&7Player &f{player} &7is already ignored"
|
already-ignored: "&7Player &6{player} &7is already ignored"
|
||||||
not-ignored: "&7Player &f{player} &7is not ignored"
|
not-ignored: "&7Player &6{player} &7is not ignored"
|
||||||
cannot-ignore-self: "&cYou cannot ignore yourself"
|
cannot-ignore-self: "&cYou cannot ignore yourself"
|
||||||
ignore-list-empty: "&7Your ignore list is empty"
|
ignore-list-empty: "&7Your ignore list is empty"
|
||||||
ignore-list-header: "&7Ignored players:"
|
ignore-list-header: "&7Ignored players:"
|
||||||
|
|
@ -97,7 +97,7 @@ general:
|
||||||
days: "d"
|
days: "d"
|
||||||
permanent: "permanent"
|
permanent: "permanent"
|
||||||
enabled: "&aEnabled"
|
enabled: "&aEnabled"
|
||||||
disabled: "&7Disabled"
|
disabled: "&cDisabled"
|
||||||
online: "&aOnline"
|
online: "&aOnline"
|
||||||
offline: "&7Offline"
|
offline: "&7Offline"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
# LoChat player messages
|
# LoChat player messages
|
||||||
# All messages sent to players. Supports & color codes and placeholders.
|
# All messages sent to players. Supports & color codes and placeholders.
|
||||||
|
|
||||||
prefix: "&8[&3LoChat&8]"
|
prefix: "&6Star Wars &7|"
|
||||||
|
|
||||||
# ── Global Chat ────────────────────────────────────────────────────────────────
|
# ── Global Chat ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
global:
|
global:
|
||||||
toggled-on: "&aGlobal chat enabled"
|
toggled-on: "&fGlobal chat enabled"
|
||||||
toggled-off: "&cGlobal chat disabled"
|
toggled-off: "&cGlobal chat disabled"
|
||||||
disabled: "&cGlobal chat is disabled on this server"
|
disabled: "&cGlobal chat is disabled on this server"
|
||||||
|
|
||||||
# ── Local Chat ─────────────────────────────────────────────────────────────────
|
# ── Local Chat ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
local:
|
local:
|
||||||
no-players: "&cNo players nearby"
|
no-players: "&7No players nearby. Start your message with &6! &7to write to everyone."
|
||||||
nobody-heard: "&7Nobody heard you — no players nearby"
|
nobody-heard: "&7Nobody heard you. Start your message with &6! &7to be heard everywhere."
|
||||||
|
|
||||||
# ── Private Messages ───────────────────────────────────────────────────────────
|
# ── Private Messages ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -29,19 +29,19 @@ pm:
|
||||||
# ── Ignore List ────────────────────────────────────────────────────────────────
|
# ── Ignore List ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ignore:
|
ignore:
|
||||||
added: "&aPlayer &f{player} &ais now ignored"
|
added: "&fPlayer &6{player} &fis now ignored"
|
||||||
removed: "&aPlayer &f{player} &ais no longer ignored"
|
removed: "&fPlayer &6{player} &fis no longer ignored"
|
||||||
already-ignored: "&7Player &f{player} &7is already ignored"
|
already-ignored: "&7Player &6{player} &7is already ignored"
|
||||||
not-ignored: "&7Player &f{player} &7is not ignored"
|
not-ignored: "&7Player &6{player} &7is not ignored"
|
||||||
self: "&cYou cannot ignore yourself"
|
self: "&cYou cannot ignore yourself"
|
||||||
list-empty: "&7Your ignore list is empty"
|
list-empty: "&7Your ignore list is empty"
|
||||||
list-header: "&8\u2500\u2500\u2500\u2500\u2500 Ignored Players \u2500\u2500\u2500\u2500\u2500"
|
list-header: "&8\u2500\u2500\u2500\u2500\u2500 Ignored Players \u2500\u2500\u2500\u2500\u2500"
|
||||||
list-footer: "&7Total: &f{count} &7players"
|
list-footer: "&7Total: &6{count} &7players"
|
||||||
|
|
||||||
# ── Announcements ──────────────────────────────────────────────────────────────
|
# ── Announcements ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
announce:
|
announce:
|
||||||
reloaded: "&aConfiguration reloaded"
|
reloaded: "&fConfiguration reloaded"
|
||||||
|
|
||||||
# ── Chat Spy ───────────────────────────────────────────────────────────────────
|
# ── Chat Spy ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ filter:
|
||||||
# ── Cooldowns ──────────────────────────────────────────────────────────────────
|
# ── Cooldowns ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
cooldown:
|
cooldown:
|
||||||
wait: "&cPlease wait &f{time} &cseconds"
|
wait: "&cPlease wait &6{time} &cseconds"
|
||||||
|
|
||||||
# ── Anti-Spam ──────────────────────────────────────────────────────────────────
|
# ── Anti-Spam ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -72,23 +72,23 @@ antispam:
|
||||||
# ── Clear Chat ─────────────────────────────────────────────────────────────────
|
# ── Clear Chat ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
clearchat:
|
clearchat:
|
||||||
cleared: "&aChat cleared by &f{player}"
|
cleared: "&fChat cleared by &6{player}"
|
||||||
|
|
||||||
# ── Errors ─────────────────────────────────────────────────────────────────────
|
# ── Errors ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
errors:
|
errors:
|
||||||
no-permission: "&cNo permission"
|
no-permission: "&cNo permission"
|
||||||
player-not-found: "&cPlayer not found"
|
player-not-found: "&cPlayer not found"
|
||||||
invalid-usage: "&cUsage: &f{usage}"
|
invalid-usage: "&cUsage: &6{usage}"
|
||||||
|
|
||||||
# ── General ────────────────────────────────────────────────────────────────────
|
# ── General ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
general:
|
general:
|
||||||
no-permission: "&cYou don't have permission!"
|
no-permission: "&cYou don't have permission!"
|
||||||
players-only: "&cThis command is for players only!"
|
players-only: "&cThis command is for players only!"
|
||||||
config-reloaded: "&aConfiguration reloaded!"
|
config-reloaded: "&fConfiguration reloaded!"
|
||||||
invalid-player: "&cPlayer &f{player} &cnot found!"
|
invalid-player: "&cPlayer &6{player} &cnot found!"
|
||||||
cooldown-active: "&cPlease wait &f{time}&cs"
|
cooldown-active: "&cPlease wait &6{time}&cs"
|
||||||
command-disabled: "&cCommand disabled!"
|
command-disabled: "&cCommand disabled!"
|
||||||
|
|
||||||
# ── Hub ────────────────────────────────────────────────────────────────────────
|
# ── Hub ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -99,55 +99,55 @@ hub:
|
||||||
# ── Mute ───────────────────────────────────────────────────────────────────────
|
# ── Mute ───────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
mute:
|
mute:
|
||||||
muted: "&aPlayer &f{player} &amuted for &f{time}"
|
muted: "&fPlayer &6{player} &fmuted for &6{time}"
|
||||||
unmuted: "&aPlayer &f{player} &aunmuted"
|
unmuted: "&fPlayer &6{player} &funmuted"
|
||||||
already-muted: "&7Player &f{player} &7is already muted"
|
already-muted: "&7Player &6{player} &7is already muted"
|
||||||
not-muted: "&7Player &f{player} &7is not muted"
|
not-muted: "&7Player &6{player} &7is not muted"
|
||||||
you-muted: "&cYou are muted! &7Remaining: &f{time}"
|
you-muted: "&cYou are muted! &7Remaining: &6{time}"
|
||||||
permanent: "&cYou are permanently muted!"
|
permanent: "&cYou are permanently muted!"
|
||||||
|
|
||||||
# ── Gradients ──────────────────────────────────────────────────────────────────
|
# ── Gradients ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
gradient:
|
gradient:
|
||||||
color-set: "&aGradient set!"
|
color-set: "&fGradient set!"
|
||||||
color-enabled: "&aGradient nickname enabled"
|
color-enabled: "&fGradient nickname enabled"
|
||||||
color-disabled: "&7Gradient nickname disabled"
|
color-disabled: "&7Gradient nickname disabled"
|
||||||
color-reset: "&7Gradient reset"
|
color-reset: "&7Gradient reset"
|
||||||
prefix-set: "&aPrefix set!"
|
prefix-set: "&aPrefix set!"
|
||||||
prefix-reset: "&7Prefix reset"
|
prefix-reset: "&7Prefix reset"
|
||||||
prefix-enabled: "&aPrefix enabled"
|
prefix-enabled: "&fPrefix enabled"
|
||||||
prefix-disabled: "&7Prefix disabled"
|
prefix-disabled: "&7Prefix disabled"
|
||||||
invalid-hex: "&cInvalid hex color: &f{color}"
|
invalid-hex: "&cInvalid hex color: &6{color}"
|
||||||
invalid-color: "&cInvalid color: &f{color}"
|
invalid-color: "&cInvalid color: &6{color}"
|
||||||
too-many-colors: "&cToo many colors! Maximum: &f{max}"
|
too-many-colors: "&cToo many colors! Maximum: &6{max}"
|
||||||
too-few-colors: "&cToo few colors! Minimum: &f{min}"
|
too-few-colors: "&cToo few colors! Minimum: &6{min}"
|
||||||
cooldown: "&cPlease wait &f{time} &cseconds"
|
cooldown: "&cPlease wait &6{time} &cseconds"
|
||||||
not-enough-points: "&cNot enough points! Need: &f{price}"
|
not-enough-points: "&cNot enough points! Need: &6{price}"
|
||||||
player-not-found: "&cPlayer not found"
|
player-not-found: "&cPlayer not found"
|
||||||
color-help: "&cUsage: &f/color <hex1> <hex2> ..."
|
color-help: "&cUsage: &6/color <hex1> <hex2> ..."
|
||||||
color-success: "&aGradient applied!"
|
color-success: "&fGradient applied!"
|
||||||
color-no-colors: "&7You have no colors set"
|
color-no-colors: "&7You have no colors set"
|
||||||
prefix-help: "&cUsage: &f/prefix <text>"
|
prefix-help: "&cUsage: &6/prefix <text>"
|
||||||
prefix-success: "&aPrefix set!"
|
prefix-success: "&aPrefix set!"
|
||||||
prefix-success-free: "&aPrefix set!"
|
prefix-success-free: "&aPrefix set!"
|
||||||
prefix-no-prefix: "&7You have no prefix"
|
prefix-no-prefix: "&7You have no prefix"
|
||||||
prefix-too-long: "&cPrefix too long! Max: &f{max}"
|
prefix-too-long: "&cPrefix too long! Max: &6{max}"
|
||||||
prefix-no-colors: "&cPrefix cannot contain color codes"
|
prefix-no-colors: "&cPrefix cannot contain color codes"
|
||||||
only-player: "&cPlayers only!"
|
only-player: "&cPlayers only!"
|
||||||
no-permission: "&cNo permission!"
|
no-permission: "&cNo permission!"
|
||||||
reload-success: "&aConfiguration reloaded!"
|
reload-success: "&fConfiguration reloaded!"
|
||||||
info-header: "&8\u2500\u2500\u2500\u2500\u2500 &f{player} &8\u2500\u2500\u2500\u2500\u2500"
|
info-header: "&8\u2500\u2500\u2500\u2500\u2500 &6{player} &8\u2500\u2500\u2500\u2500\u2500"
|
||||||
info-prefix: "&7Prefix: &f{prefix}"
|
info-prefix: "&7Prefix: &6{prefix}"
|
||||||
info-colors: "&7Colors: &f{colors}"
|
info-colors: "&7Colors: &6{colors}"
|
||||||
|
|
||||||
# ── Custom Nicknames ───────────────────────────────────────────────────────────
|
# ── Custom Nicknames ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
nick:
|
nick:
|
||||||
current: "&7Your nickname: &f{nick}"
|
current: "&7Your nickname: &6{nick}"
|
||||||
usage: "&cUsage: &f/nick <name> &7or &f/nick reset"
|
usage: "&cUsage: &6/nick <name> &7or &6/nick reset"
|
||||||
no-nick: "&7You have no custom nickname"
|
no-nick: "&7You have no custom nickname"
|
||||||
reset-success: "&aNickname reset!"
|
reset-success: "&fNickname reset!"
|
||||||
set-success: "&aNickname changed to: &f{nick}"
|
set-success: "&fNickname changed to: &6{nick}"
|
||||||
invalid: "&cInvalid nickname!"
|
invalid: "&cInvalid nickname!"
|
||||||
taken: "&cThis nickname is already taken!"
|
taken: "&cThis nickname is already taken!"
|
||||||
error: "&cFailed to set nickname!"
|
error: "&cFailed to set nickname!"
|
||||||
|
|
@ -156,11 +156,11 @@ nick:
|
||||||
|
|
||||||
rp:
|
rp:
|
||||||
me: "&7* {player} {action}"
|
me: "&7* {player} {action}"
|
||||||
try-format: "&7* {player} tries {action}... &f{result}"
|
try-format: "&7* {player} tries {action}... &6{result}"
|
||||||
try-success: "&a[Success!]"
|
try-success: "&a[Success!]"
|
||||||
try-fail: "&c[Fail!]"
|
try-fail: "&c[Fail!]"
|
||||||
do-format: "&8[Scene] &f{description} &7({player})"
|
do-format: "&8[Scene] &6{description} &7({player})"
|
||||||
roll-format: "&7* {player} rolls &f{result} &7out of &f{max}"
|
roll-format: "&7* {player} rolls &6{result} &7out of &6{max}"
|
||||||
roll-min-error: "&cMinimum dice value: 2"
|
roll-min-error: "&cMinimum dice value: 2"
|
||||||
roll-max-error: "&cMaximum dice value: 1000000"
|
roll-max-error: "&cMaximum dice value: 1000000"
|
||||||
roll-invalid-number: "&cPlease specify a number: /roll [max]"
|
roll-invalid-number: "&cPlease specify a number: /roll [max]"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue