Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.UUID;
Expand Down Expand Up @@ -66,6 +67,8 @@ public class ChangelogEditorDialog implements IDialog {
List<Component> previewLines = session.deserializeLines();
BiFunction<Component, Integer, Component> lineMapper = (line, idx) -> {
boolean isEditing = session.getEditingLineIndex() == idx;
boolean canMoveUp = idx > 0;
boolean canMoveDown = idx < previewLines.size() - 1;
if (isEditing) line = line.decoration(TextDecoration.BOLD, true);
String prefix = "dialog.editor.line_action.";
NBTCompound payload = new NBTCompound();
Expand All @@ -74,12 +77,19 @@ public class ChangelogEditorDialog implements IDialog {
if (!isEditing) edit = translatableManual(p, prefix + "format", translatable(prefix + "edit"))
.decoration(TextDecoration.BOLD, false)
.clickEvent(sessionManager.createSessionBasedClickEvent(this, "start_edit_line", payload));

Component remove = translatableManual(p, prefix + "format", translatable(prefix + "remove"))
.decoration(TextDecoration.BOLD, false)
.clickEvent(sessionManager.createSessionBasedClickEvent(this, "remove_line", payload));
Component up = translatableManual(p, prefix + "format", translatable(prefix + "move_up." + this.getToggleState(canMoveUp)))
.decoration(TextDecoration.BOLD, false)
.clickEvent(canMoveUp ? sessionManager.createSessionBasedClickEvent(this, "move_line_up", payload) : null);
Component down = translatableManual(p, prefix + "format", translatable(prefix + "move_down." + this.getToggleState(canMoveDown)))
.decoration(TextDecoration.BOLD, false)
.clickEvent(canMoveDown ? sessionManager.createSessionBasedClickEvent(this, "move_line_down", payload) : null);
Component finalCmp = line.appendSpace();
if (!isEditing) finalCmp = finalCmp.append(edit).appendSpace();
return finalCmp.append(remove);
return finalCmp.append(up).appendSpace().append(down).appendSpace().append(remove);
};
ItemDialogBody itemBody = new ItemDialogBody(ItemStack.builder().type(ItemTypes.WRITABLE_BOOK).build(),
new PlainMessage(translatableManual(p, sessionTranslation + ".subtitle", text(session.getEntryUID() + 1)), 160),
Expand Down Expand Up @@ -179,6 +189,18 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data
session.setCurrentLine("");
this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY);
}
case "move_line_up", "move_line_down" -> {
if (data == null) return;
int lineIdx = data.getNumberTagValueOrThrow("target_line").intValue();
int newIdx = action.equals("move_line_up") ? lineIdx - 1 : lineIdx + 1;
List<String> lines = session.getRawLines();
if (lineIdx < 0 || lineIdx >= lines.size() || newIdx < 0 || newIdx >= lines.size()) return;
Collections.swap(lines, lineIdx, newIdx);
int editing = session.getEditingLineIndex();
if (editing == lineIdx) session.setEditingLineIndex(newIdx);
else if (editing == newIdx) session.setEditingLineIndex(lineIdx);
this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY);
}
case "start_edit_line", "remove_line" -> {
if (data == null) return;
int lineIdx = data.getNumberTagValueOrThrow("target_line").intValue();
Expand Down Expand Up @@ -229,4 +251,7 @@ private void showRetry(@NotNull IPlayer source, @NotNull String errorPart, @NotN
public static boolean permissionCheck(@NotNull String permission, @NotNull IPlayer source, boolean useFallbackPermissions) {
return useFallbackPermissions ? source.isNativeAdmin().toBooleanOrElse(false) : source.hasPermission(ServerChangelogs.NAMESPACE + "." + permission);
}
private String getToggleState(boolean state) {
return state ? "active" : "disabled";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.codingcat.changelogs.base.lang;

import org.jetbrains.annotations.NotNull;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* Inserts keys that exist in a reference YAML text but are missing from a target YAML text, placing each one under
* its parent key in the same order as the reference. The target is edited as plain text, so its existing content,
* comments and formatting are kept exactly as they were.
* <p>
* Only the simple structure used by the lang files is understood (nested maps with string values, including
* block scalars). Keys that can't be placed, for example because the target has a string where the reference has
* a map, are left out.
*/
final class LangFileMerger {
private static final Pattern KEY = Pattern.compile("^(\\s*)(\"[^\"]*\"|'[^']*'|[^\\s#:][^:]*?)\\s*:(?:\\s+(.*))?$");
private static final Pattern BLOCK_SCALAR = Pattern.compile("^[|>][+-]?\\d*\\s*(#.*)?$");

private record Node(@NotNull List<String> path, int indent, int start, int end, boolean leaf) {
}

private LangFileMerger() {
}

static @NotNull String merge(@NotNull String target, @NotNull String reference) {
String separator = target.contains("\r\n") ? "\r\n" : "\n";
boolean trailingNewline = target.endsWith("\n");
List<String> lines = split(target);
List<String> referenceLines = split(reference);
List<Node> referenceNodes = parse(referenceLines);
Map<List<String>, Node> referenceByPath = byPath(referenceNodes);
boolean changed = true;
while (changed) {
changed = false;
Map<List<String>, Node> targetByPath = byPath(parse(lines));
for (Node node : referenceNodes) {
if (targetByPath.containsKey(node.path())) continue;
List<String> parentPath = node.path().subList(0, node.path().size() - 1);
Node parent = parentPath.isEmpty() ? null : targetByPath.get(parentPath);
if (!parentPath.isEmpty() && (parent == null || parent.leaf())) continue;
int childIndent = parent == null ? node.indent() : parent.indent() + (node.indent() - referenceByPath.get(parentPath).indent());
int insertAt = parent == null ? lastContentLine(lines) + 1 : parent.end();
lines.addAll(insertAt, createLines(referenceLines, node, childIndent));
changed = true;
break;
}
}
return String.join(separator, lines) + (trailingNewline ? separator : "");
}

private static @NotNull List<String> split(@NotNull String text) {
List<String> lines = new ArrayList<>(Arrays.asList(text.split("\r?\n", -1)));
if (!lines.isEmpty() && lines.getLast().isEmpty()) lines.removeLast();
return lines;
}

private static @NotNull Map<List<String>, Node> byPath(@NotNull List<Node> nodes) {
Map<List<String>, Node> map = new HashMap<>();
nodes.forEach(n -> map.putIfAbsent(n.path(), n));
return map;
}

private static @NotNull List<String> createLines(@NotNull List<String> referenceLines, @NotNull Node node, int childIndent) {
int first = node.start();
while (first > 0 && referenceLines.get(first - 1).trim().startsWith("#")) first--;
int last = node.leaf() ? node.end() : node.start() + 1;
int shift = childIndent - node.indent();
List<String> result = new ArrayList<>();
for (String line : referenceLines.subList(first, last)) {
if (line.isBlank()) result.add("");
else if (shift >= 0) result.add(" ".repeat(shift) + line);
else result.add(line.substring(Math.min(-shift, indentOf(line))));
}
return result;
}

private static @NotNull List<Node> parse(@NotNull List<String> lines) {
List<Node> nodes = new ArrayList<>();
List<Integer> stackIndents = new ArrayList<>();
List<String> stackKeys = new ArrayList<>();
int blockIndent = -1;
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
if (line.isBlank() || line.trim().startsWith("#")) continue;
int indent = indentOf(line);
if (blockIndent >= 0) {
if (indent > blockIndent) continue;
blockIndent = -1;
}
Matcher matcher = KEY.matcher(line);
if (!matcher.matches()) continue;
while (!stackIndents.isEmpty() && stackIndents.getLast() >= indent) {
stackIndents.removeLast();
stackKeys.removeLast();
}
stackIndents.add(indent);
stackKeys.add(unquote(matcher.group(2)));
String value = matcher.group(3) == null ? "" : matcher.group(3).trim();
if (BLOCK_SCALAR.matcher(value).matches()) blockIndent = indent;
boolean leaf = !value.isEmpty() && !value.startsWith("#");
nodes.add(new Node(List.copyOf(stackKeys), indent, i, subtreeEnd(lines, i, indent), leaf));
}
return nodes;
}

/**
* The line after the last content line (blank and comment-only lines excluded) that belongs to the key at {@code start}.
*/
private static int subtreeEnd(@NotNull List<String> lines, int start, int indent) {
int lastContent = start;
for (int j = start + 1; j < lines.size(); j++) {
String line = lines.get(j);
if (line.isBlank() || line.trim().startsWith("#")) continue;
if (indentOf(line) <= indent) break;
lastContent = j;
}
return lastContent + 1;
}

private static int lastContentLine(@NotNull List<String> lines) {
for (int i = lines.size() - 1; i >= 0; i--) if (!lines.get(i).isBlank()) return i;
return -1;
}

private static int indentOf(@NotNull String line) {
int i = 0;
while (i < line.length() && line.charAt(i) == ' ') i++;
return i;
}

private static @NotNull String unquote(@NotNull String key) {
if (key.length() >= 2 && (key.startsWith("\"") && key.endsWith("\"") || key.startsWith("'") && key.endsWith("'")))
return key.substring(1, key.length() - 1);
return key;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,24 @@
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.error.YAMLException;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;

import static net.kyori.adventure.text.Component.text;

@RequiredArgsConstructor
public final class TranslationSource {
private static final String FALLBACK_LANGUAGE_FILE = "en_US.yml";
private final @NotNull Path sourceDirectory;
private final @NotNull ChangelogsMeta changelogsMeta;
private final @NotNull PlatformMeta platformMeta;
Expand All @@ -58,6 +63,7 @@ public void reload() {
String withoutExtension = file.getName().substring(0, file.getName().lastIndexOf('.'));
Map<String, String> data = this.loadTranslationFile(file.toPath());
if (data == null) continue;
this.addMissingKeys(file, data);
rawTranslations.put(withoutExtension, data);
}
GlobalTranslator globalTranslator = GlobalTranslator.translator();
Expand Down Expand Up @@ -108,6 +114,72 @@ public void reload() {
return null;
}

/**
* Adds any keys that exist in the bundled defaults but are missing from the server's copy of the file, so
* new translations show up after an update without the server owner having to delete their lang files.
* Bundled languages are updated from their own built-in file, any other language from the built-in
* {@value #FALLBACK_LANGUAGE_FILE} so it can be translated. Missing keys are inserted next to their siblings and
* existing content is never modified.
*/
private void addMissingKeys(@NotNull File file, @NotNull Map<String, String> translations) {
String fileName = file.getName();
String referenceText = this.loadBundledText(fileName);
if (referenceText == null) referenceText = this.loadBundledText(FALLBACK_LANGUAGE_FILE);
if (referenceText == null) return;
Map<String, String> defaults = new HashMap<>();
try {
this.addRawTranslationsRecursive(defaults, ServerChangelogs.NAMESPACE + ".", new Yaml().load(referenceText));
} catch (YAMLException e) {
logger.warn("Failed to read bundled translations for {}:", fileName, e);
return;
}
Map<String, String> missing = new LinkedHashMap<>();
defaults.forEach((k, v) -> {
if (!translations.containsKey(k)) missing.put(k, v);
});
if (missing.isEmpty()) return;
try {
String existing = Files.readString(file.toPath(), StandardCharsets.UTF_8);
String merged = LangFileMerger.merge(existing, referenceText);
Map<String, String> stillMissing = new LinkedHashMap<>(missing);
try {
Map<String, String> mergedKeys = new HashMap<>();
this.addRawTranslationsRecursive(mergedKeys, ServerChangelogs.NAMESPACE + ".", new Yaml().load(merged));
stillMissing.keySet().removeIf(mergedKeys::containsKey);
} catch (YAMLException e) {
merged = existing;
}
if (merged.equals(existing)) stillMissing = new LinkedHashMap<>(missing);
// Anything that couldn't be placed in the existing structure is appended as a dotted key instead
if (!stillMissing.isEmpty()) merged = this.appendDotted(merged, stillMissing);
Files.writeString(file.toPath(), merged, StandardCharsets.UTF_8);
} catch (IOException e) {
logger.warn("Failed to add missing translations to {}:", fileName, e);
}
logger.info("Added {} missing translation key(s) to language file {}", missing.size(), fileName);
missing.forEach(translations::putIfAbsent);
}

private @NotNull String appendDotted(@NotNull String text, @NotNull Map<String, String> keys) {
Map<String, Object> dotted = new LinkedHashMap<>();
String namespacePrefix = ServerChangelogs.NAMESPACE + ".";
keys.forEach((k, v) -> dotted.put(k.substring(namespacePrefix.length()), v));
DumperOptions options = new DumperOptions();
options.setDefaultScalarStyle(DumperOptions.ScalarStyle.DOUBLE_QUOTED);
options.setWidth(Integer.MAX_VALUE);
String separator = !text.isEmpty() && !text.endsWith("\n") ? "\n\n" : "\n";
return text + separator + "# New translations added automatically by a plugin update, edit or translate them as needed:\n" + new Yaml(options).dump(dotted);
}

private @Nullable String loadBundledText(@NotNull String fileName) {
try (InputStream stream = TranslationSource.class.getClassLoader().getResourceAsStream("lang/" + fileName)) {
return stream == null ? null : new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
logger.warn("Failed to read bundled translations {}:", fileName, e);
return null;
}
}

private void addRawTranslationsRecursive(@NotNull Map<String, String> rawTranslations, @NotNull String prefix, @NotNull Map<String, Object> data) {
data.forEach((k, v) -> {
if (v instanceof String str) rawTranslations.put(prefix + k, str);
Expand Down
6 changes: 6 additions & 0 deletions base/src/main/resources/lang/en_US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ dialog: # Translations for the plugin's dialog UIs
format: "<dark_gray>[<arg:0>]"
edit: "<light_purple><hover:show_text:'<light_purple>✎ Edit line'>✎"
remove: "<red><hover:show_text:'<red>❌ Remove line'>❌"
move_up:
active: "<green><hover:show_text:'<green>▲ Move Line Up'>▲"
disabled: "<dark_gray>▲"
move_down:
active: "<green><hover:show_text:'<green>▼ Move Line Down'>▼"
disabled: "<dark_gray>▼"
confirm_close:
button:
save_session: "<translate:icon.letter> <green><b>Save and Close"
Expand Down
6 changes: 6 additions & 0 deletions base/src/main/resources/lang/ru_RU.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ dialog: # Переводы диалоговых интерфейсов (UI) пл
format: "<dark_gray>[<arg:0>]"
edit: "<light_purple><hover:show_text:'<light_purple>✎ Редактировать строку'>✎"
remove: "<red><hover:show_text:'<red>❌ Удалить строку'>❌"
move_up:
active: "<green><hover:show_text:'<green>▲ Переместить строку вверх'>▲"
disabled: "<dark_gray>▲"
move_down:
active: "<green><hover:show_text:'<green>▼ Переместить строку вниз'>▼"
disabled: "<dark_gray>▼"
confirm_close:
button:
save_session: "<translate:icon.letter> <green><b>Сохранить и закрыть"
Expand Down
Loading