diff --git a/README.md b/README.md index bf6d6ab..6b8f8a4 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ Currently, the plugin supports the following features: - [**MiniMessage**](https://docs.papermc.io/adventure/minimessage/format/) support for **creating changelogs** - A **completely customizable** changelog screen - Customizable **translations** based on the player's **client language** +- Optional support for **legacy `&`-based color/formatting codes** (e.g. `&a`, `&l`, `&r`) alongside MiniMessage +- **Pre-defined authors**, configurable in `config.yml`, that can be quick-selected in the editor dialog ## Supported Server Software & Versions ServerChangelogs is currently available as: @@ -114,7 +116,7 @@ ServerChangelogs was originally made for a **smaller minecraft server** I'm a pa The roadmap below contains some of the features that I'm **planning to add** in the future: - [ ] **JDBC (relational) database support**: Currently, the plugin stores all changelog data (including who has already seen what changelogs) in a plain YAML file. The reason for this mostly comes down to the stuff above (we wanted a solution fast), so we didn't prioritize storage. This does start to cause problems over time, since the amount of data that has to be (de-)serialized for each startup/write operation increases by a lot! A proper implementation would also have to consider things like auto-migration from the existing YAML files. -- [ ] **Pre-defined authors**: Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. +- [x] **Pre-defined authors**: Rather than having to enter the name of the changelog's author every time (possibly including formatting), there could be a list of pre-defined authors in the plugin config that a user could choose from. - [ ] **Customizable changelog dialog layout**: Currently, the changelog dialog always shows all previous changelogs, with unread ones marked red. In the future, it'd be nice to have an option for this, e.g. so players on join could also only see the most recent changelog. - [ ] **Paged changelogs**: At the moment, all existing changelogs are just added to the dialog without any additional checks. While this is fine with just a few of them, it could quickly become an issue with a long history of changelogs. So adding a page system with a configurable amount of changelogs per page would be great! - [ ] **Player first-join storage**: Right now, changelogs are just displayed to players upon joining whenever they haven't read them (except for the **first time** a player joins a server). This can be very confusing to new-ish players, since they weren't there for all the previous changes to begin with! A possible (while not 100% perfect) solution to this would be storing when the player initially joined the server / was first seen by the plugin, to then only display relevant changelogs to them. diff --git a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java b/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java index d012551..2a4ca20 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java +++ b/base/src/main/java/com/codingcat/changelogs/base/ServerChangelogs.java @@ -9,6 +9,7 @@ import com.codingcat.changelogs.base.dialog.IDialog; import com.codingcat.changelogs.base.event.ChangelogJoinListener; import com.codingcat.changelogs.base.lang.TranslationSource; +import com.codingcat.changelogs.base.util.LegacyColorTranslator; import com.codingcat.changelogs.base.util.ResourceUtil; import com.codingcat.changelogs.platformapi.ChangelogsPlatform; import com.codingcat.changelogs.platformapi.Entrypoint; @@ -58,16 +59,17 @@ public ServerChangelogs(@NotNull ChangelogsPlatform platform) { public void onStart() { logger = getPlatform().getComponentLogger(); Path translationPath = getPlatform().getDataPath().resolve("lang"); - if (translationPath.toFile().mkdirs()) { - logger.info("Creating default translation files..."); - ResourceUtil.readResourcesAsString("lang").forEach((fname, contents) -> { - try { - Files.writeString(translationPath.resolve(fname), contents, StandardCharsets.UTF_8, StandardOpenOption.CREATE); - } catch (IOException e) { - logger.warn("Failed to create default translation file \"{}\":", fname, e); - } - }); - } + translationPath.toFile().mkdirs(); + ResourceUtil.readResourcesAsString("lang").forEach((fname, contents) -> { + Path targetFile = translationPath.resolve(fname); + if (targetFile.toFile().exists()) return; + logger.info("Creating missing default translation file \"{}\"...", fname); + try { + Files.writeString(targetFile, contents, StandardCharsets.UTF_8, StandardOpenOption.CREATE); + } catch (IOException e) { + logger.warn("Failed to create default translation file \"{}\":", fname, e); + } + }); this.translationSource = new TranslationSource(translationPath, getPlatform().getChangelogsMeta(), getPlatform().getPlatformMeta(), logger); this.translationSource.reload(); info("console.startup"); @@ -83,6 +85,7 @@ public void onStart() { } this.config = new PluginConfig(this, configPath); this.config.tryReload(); + LegacyColorTranslator.setEnabled(this.config.useLegacyColorCodes()); this.changelogStorage = this.config.createChangelogStorage(); info("console.startup_storage", text(this.changelogStorage.getDisplayName())); this.changelogStorage.init(); @@ -111,6 +114,7 @@ public void reload(boolean force) throws IOException, YAMLException, IDialog.Des this.config.reload(); String err = this.config.validate(); if (err != null) throw new YAMLException(err); + LegacyColorTranslator.setEnabled(this.config.useLegacyColorCodes()); this.changelogStorage.shutdown(); this.changelogStorage = this.config.createChangelogStorage(); info("console.startup_storage", text(this.changelogStorage.getDisplayName())); diff --git a/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java b/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java index 12ecf98..4322e75 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java +++ b/base/src/main/java/com/codingcat/changelogs/base/config/PluginConfig.java @@ -49,6 +49,7 @@ public void reload() throws IOException, YAMLException { getDateFormatter(); createChangelogHeaderStack(); getEnabledManualWorkarounds(); + getPredefinedAuthors(); return null; } catch (Exception e) { return e.getMessage(); @@ -96,6 +97,14 @@ public boolean useNativeFallbackPermissions() { return getBoolean("use_native_fallback_permissions", false); } + public boolean useLegacyColorCodes() { + return getBoolean("legacy_color_codes", true); + } + + public @NotNull List getPredefinedAuthors() { + return getList("predefined_authors", String.class); + } + public @Nullable ItemStack createChangelogHeaderStack() { String value = getString("dialog_header_item", null); return value != null ? createStack(value) : null; diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java index bcb3ebe..89853ca 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java +++ b/base/src/main/java/com/codingcat/changelogs/base/dialog/IDialog.java @@ -44,7 +44,8 @@ public void recreate() { this.dialogMap.clear(); register.accept(new ChangelogEditorDialog( plugin.getChangelogStorage(), - plugin.pluginConfig().useNativeFallbackPermissions() + plugin.pluginConfig().useNativeFallbackPermissions(), + plugin.pluginConfig().getPredefinedAuthors() )); register.accept(new ChangelogDialog( plugin.getChangelogStorage(), this, 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..fc6584f 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 @@ -30,6 +30,7 @@ import lombok.RequiredArgsConstructor; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -53,6 +54,7 @@ public class ChangelogEditorDialog implements IDialog { private final @Getter String id = "changelog_editor"; private final @NotNull ChangelogStorage storage; private final boolean useFallbackPermissions; + private final @NotNull List predefinedAuthors; @Override public @NotNull Dialog build(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { @@ -85,14 +87,14 @@ public class ChangelogEditorDialog implements IDialog { new PlainMessage(translatableManual(p, sessionTranslation + ".subtitle", text(session.getEntryUID() + 1)), 160), false, false, 15, 15); PacketEventsFix.fixItemBody(itemBody); - List body = List.of(itemBody, + List body = new ArrayList<>(List.of(itemBody, new PlainMessageDialogBody(new PlainMessage(translatableManual(p, "dialog.editor.hint"), ChangelogDialog.LINE_WIDTH)), new PlainMessageDialogBody(new PlainMessage(!previewLines.isEmpty() ? createLinesComponent(p, lineMapper, previewLines) : translatableManual(p, "dialog.editor.empty_preview"), ChangelogDialog.LINE_WIDTH)) - ); - if (session.isShowRestoredMessage()) { - body = new ArrayList<>(body); + )); + if (session.isShowRestoredMessage()) body.add(1, new PlainMessageDialogBody(new PlainMessage(translatableManual(p, "dialog.editor.restored_session"), 400))); - } + if (!predefinedAuthors.isEmpty()) + body.add(new PlainMessageDialogBody(new PlainMessage(createPredefinedAuthorsComponent(p, sessionManager), ChangelogDialog.LINE_WIDTH))); List inputs = List.of( new Input("line", new TextInputControl(350, translatableManual(p, "dialog.editor.input.contents"), true, session.getCurrentLine(), 5000, null)), @@ -111,6 +113,21 @@ public class ChangelogEditorDialog implements IDialog { return new MultiActionDialog(common, buttons, closeBtn, 3); } + private @NotNull Component createPredefinedAuthorsComponent(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { + String prefix = "dialog.editor.predefined_authors."; + Component entries = Component.empty(); + for (int i = 0; i < predefinedAuthors.size(); i++) { + Component parsedAuthor = MiniMessage.miniMessage().deserialize(predefinedAuthors.get(i)); + NBTCompound payload = new NBTCompound(); + payload.setTag("author_index", new NBTInt(i)); + Component entry = translatableManual(p, prefix + "entry", parsedAuthor) + .clickEvent(sessionManager.createSessionBasedClickEvent(this, "select_predefined_author", payload)); + entries = entries.append(entry); + if (i < predefinedAuthors.size() - 1) entries = entries.appendSpace(); + } + return translatableManual(p, prefix + "label", entries); + } + private @NotNull Dialog buildConfirmCloseDialog(@NotNull IPlayer p, @NotNull DialogSessionManager sessionManager) { EditorSession session = sessionManager.getSessionData(p, EditorSession.class); String prefix = "dialog.editor.confirm_close."; @@ -179,6 +196,13 @@ public void onActionTriggered(@NotNull String action, @Nullable NBTCompound data session.setCurrentLine(""); this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); } + case "select_predefined_author" -> { + if (data == null) return; + int authorIndex = data.getNumberTagValueOrThrow("author_index").intValue(); + if (authorIndex < 0 || authorIndex >= predefinedAuthors.size()) return; + session.setAuthor(predefinedAuthors.get(authorIndex)); + this.showTo(source, sessionManager, DialogPackets.PacketPhase.PLAY); + } case "start_edit_line", "remove_line" -> { if (data == null) return; int lineIdx = data.getNumberTagValueOrThrow("target_line").intValue(); diff --git a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java index 979118e..09061b1 100644 --- a/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java +++ b/base/src/main/java/com/codingcat/changelogs/base/dialog/ui/editor/EditorSession.java @@ -2,6 +2,7 @@ import com.codingcat.changelogs.base.data.ChangelogEntry; import com.codingcat.changelogs.base.data.ChangelogStorage; +import com.codingcat.changelogs.base.util.LegacyColorTranslator; import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -37,12 +38,13 @@ public abstract class EditorSession { public @NotNull @Unmodifiable List deserializeLines() { return getRawLines().stream() + .map(LegacyColorTranslator::translate) .map(MiniMessage.miniMessage()::deserialize) .toList(); } public @Nullable Component deserializeAuthor() { - return !author.isBlank() ? MiniMessage.miniMessage().deserialize(getAuthor()) : null; + return !author.isBlank() ? MiniMessage.miniMessage().deserialize(LegacyColorTranslator.translate(getAuthor())) : null; } public static class Create extends EditorSession { diff --git a/base/src/main/java/com/codingcat/changelogs/base/util/LegacyColorTranslator.java b/base/src/main/java/com/codingcat/changelogs/base/util/LegacyColorTranslator.java new file mode 100644 index 0000000..f9f06a6 --- /dev/null +++ b/base/src/main/java/com/codingcat/changelogs/base/util/LegacyColorTranslator.java @@ -0,0 +1,69 @@ +package com.codingcat.changelogs.base.util; + +import org.jetbrains.annotations.NotNull; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Translates legacy ampersand-based formatting codes (e.g. {@code &a}, {@code &l}, {@code &r}) + * into their MiniMessage tag equivalents, so that changelog authors who are used to the + * legacy formatting system can keep using it, optionally alongside regular MiniMessage tags. + *

+ * Color codes are translated into a {@code } tag followed by the matching color tag, + * mirroring vanilla behavior where selecting a new color also clears any previously active + * formatting (bold, italic, etc.). Formatting codes (bold, italic, ...) simply stack on top of + * each other, and {@code &r} maps directly to {@code }. + */ +public final class LegacyColorTranslator { + private static final @NotNull Pattern LEGACY_CODE_PATTERN = Pattern.compile("&([0-9a-fk-orA-FK-OR])"); + + private static final @NotNull Map COLORS = Map.ofEntries( + Map.entry('0', "black"), Map.entry('1', "dark_blue"), Map.entry('2', "dark_green"), + Map.entry('3', "dark_aqua"), Map.entry('4', "dark_red"), Map.entry('5', "dark_purple"), + Map.entry('6', "gold"), Map.entry('7', "gray"), Map.entry('8', "dark_gray"), + Map.entry('9', "blue"), Map.entry('a', "green"), Map.entry('b', "aqua"), + Map.entry('c', "red"), Map.entry('d', "light_purple"), Map.entry('e', "yellow"), + Map.entry('f', "white") + ); + + private static final @NotNull Map FORMATS = Map.of( + 'k', "obfuscated", 'l', "bold", 'm', "strikethrough", 'n', "underlined", 'o', "italic" + ); + + private static volatile boolean enabled = true; + + private LegacyColorTranslator() { + } + + /** + * Enables/disables legacy code translation plugin-wide. Should be called whenever the + * plugin configuration is (re-)loaded, mirroring the {@code legacy_color_codes} option. + */ + public static void setEnabled(boolean isEnabled) { + enabled = isEnabled; + } + + /** + * Replaces every legacy formatting code in the given raw (unparsed) string with its + * MiniMessage tag equivalent. Regular MiniMessage tags already present in the input are + * left untouched, so both can be freely mixed. Returns the input unchanged if legacy code + * translation is currently disabled, or if the input doesn't contain any {@code &} at all. + */ + public static @NotNull String translate(@NotNull String rawInput) { + if (!enabled || rawInput.indexOf('&') == -1) return rawInput; + Matcher matcher = LEGACY_CODE_PATTERN.matcher(rawInput); + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + char code = Character.toLowerCase(matcher.group(1).charAt(0)); + String replacement; + if (code == 'r') replacement = ""; + else if (COLORS.containsKey(code)) replacement = "<" + COLORS.get(code) + ">"; + else replacement = "<" + FORMATS.get(code) + ">"; + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(result); + return result.toString(); + } +} diff --git a/base/src/main/resources/defaults/config.yml b/base/src/main/resources/defaults/config.yml index c27abf4..9f1f408 100644 --- a/base/src/main/resources/defaults/config.yml +++ b/base/src/main/resources/defaults/config.yml @@ -57,6 +57,21 @@ dialog_header: true # Example: "minecraft:paper[minecraft:item_model='my_resourcepack:newspaper_item']" dialog_header_item: "minecraft:written_book" +# Whether legacy "&"-based color/formatting codes (e.g. &a, &l, &r) should automatically +# be translated into their MiniMessage equivalent when writing changelog lines/authors. +# This is purely additive - regular MiniMessage tags can still be used at the same time. +# Disable this if you don't want "&" to be treated as a formatting character. +legacy_color_codes: true + +# A list of pre-defined authors that can be quickly selected when creating/editing a +# changelog in the editor dialog, instead of having to type one out (with formatting) every time. +# Entries support MiniMessage formatting, just like the "author" input field itself. +# Leave this empty to not show any quick-select options. +predefined_authors: [] +#predefined_authors: +# - "Notch" +# - "Admin Team" + # -- DO NOT MODIFY UNLESS YOU KNOW WHAT YOU ARE DOING -- # Manually enable workarounds/fixes for certain bugs in e.g. PacketEvents # that normally don't apply to the current version diff --git a/base/src/main/resources/lang/en_US.yml b/base/src/main/resources/lang/en_US.yml index da9eab0..2bfb49e 100644 --- a/base/src/main/resources/lang/en_US.yml +++ b/base/src/main/resources/lang/en_US.yml @@ -64,6 +64,9 @@ dialog: # Translations for the plugin's dialog UIs input: contents: "Line Content" author: "Author (optional)" + predefined_authors: + label: " Quick-select author: " + entry: "[Click to use this author'>]" button: add_line: " Add Line" edit_line: " Edit Line" diff --git a/base/src/main/resources/lang/ru_RU.yml b/base/src/main/resources/lang/ru_RU.yml index 690ac85..b537bbe 100644 --- a/base/src/main/resources/lang/ru_RU.yml +++ b/base/src/main/resources/lang/ru_RU.yml @@ -64,6 +64,9 @@ dialog: # Переводы диалоговых интерфейсов (UI) пл input: contents: "Текст строки" author: "Автор (необязательно)" + predefined_authors: + label: " Быстрый выбор автора: " + entry: "[Нажмите, чтобы выбрать этого автора'>]" button: add_line: " Добавить строку" edit_line: " Изменить строку"