diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java index c60eb97..1be9050 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java +++ b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/ChangelogEditorDialog.java @@ -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; @@ -66,6 +67,8 @@ public class ChangelogEditorDialog implements IDialog { List previewLines = session.deserializeLines(); BiFunction 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(); @@ -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), @@ -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 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(); @@ -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"; + } } diff --git a/base/src/main/java/com/codingcat/changelogs/base/lang/LangFileMerger.java b/base/src/main/java/com/codingcat/changelogs/base/lang/LangFileMerger.java new file mode 100644 index 0000000..4ab3e21 --- /dev/null +++ b/base/src/main/java/com/codingcat/changelogs/base/lang/LangFileMerger.java @@ -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. + *

+ * 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 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 lines = split(target); + List referenceLines = split(reference); + List referenceNodes = parse(referenceLines); + Map, Node> referenceByPath = byPath(referenceNodes); + boolean changed = true; + while (changed) { + changed = false; + Map, Node> targetByPath = byPath(parse(lines)); + for (Node node : referenceNodes) { + if (targetByPath.containsKey(node.path())) continue; + List 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 split(@NotNull String text) { + List lines = new ArrayList<>(Arrays.asList(text.split("\r?\n", -1))); + if (!lines.isEmpty() && lines.getLast().isEmpty()) lines.removeLast(); + return lines; + } + + private static @NotNull Map, Node> byPath(@NotNull List nodes) { + Map, Node> map = new HashMap<>(); + nodes.forEach(n -> map.putIfAbsent(n.path(), n)); + return map; + } + + private static @NotNull List createLines(@NotNull List 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 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 parse(@NotNull List lines) { + List nodes = new ArrayList<>(); + List stackIndents = new ArrayList<>(); + List 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 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 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; + } +} diff --git a/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java b/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java index cfabe13..2204c82 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java +++ b/base/src/main/java/com/codingcat/changelogs/base/lang/TranslationSource.java @@ -19,12 +19,16 @@ 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.*; @@ -32,6 +36,7 @@ @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; @@ -58,6 +63,7 @@ public void reload() { String withoutExtension = file.getName().substring(0, file.getName().lastIndexOf('.')); Map data = this.loadTranslationFile(file.toPath()); if (data == null) continue; + this.addMissingKeys(file, data); rawTranslations.put(withoutExtension, data); } GlobalTranslator globalTranslator = GlobalTranslator.translator(); @@ -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 translations) { + String fileName = file.getName(); + String referenceText = this.loadBundledText(fileName); + if (referenceText == null) referenceText = this.loadBundledText(FALLBACK_LANGUAGE_FILE); + if (referenceText == null) return; + Map 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 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 stillMissing = new LinkedHashMap<>(missing); + try { + Map 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 keys) { + Map 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 rawTranslations, @NotNull String prefix, @NotNull Map data) { data.forEach((k, v) -> { if (v instanceof String str) rawTranslations.put(prefix + k, str); diff --git a/base/src/main/resources/lang/en_US.yml b/base/src/main/resources/lang/en_US.yml index da9eab0..50ffabf 100644 --- a/base/src/main/resources/lang/en_US.yml +++ b/base/src/main/resources/lang/en_US.yml @@ -72,6 +72,12 @@ dialog: # Translations for the plugin's dialog UIs format: "[]" edit: "✎ Edit line'>✎" remove: "❌ Remove line'>❌" + move_up: + active: "▲ Move Line Up'>▲" + disabled: "▲" + move_down: + active: "▼ Move Line Down'>▼" + disabled: "▼" confirm_close: button: save_session: " Save and Close" diff --git a/base/src/main/resources/lang/ru_RU.yml b/base/src/main/resources/lang/ru_RU.yml index 690ac85..4cde446 100644 --- a/base/src/main/resources/lang/ru_RU.yml +++ b/base/src/main/resources/lang/ru_RU.yml @@ -72,6 +72,12 @@ dialog: # Переводы диалоговых интерфейсов (UI) пл format: "[]" edit: "✎ Редактировать строку'>✎" remove: "❌ Удалить строку'>❌" + move_up: + active: "▲ Переместить строку вверх'>▲" + disabled: "▲" + move_down: + active: "▼ Переместить строку вниз'>▼" + disabled: "▼" confirm_close: button: save_session: " Сохранить и закрыть"