Repository: pajlads/DinkPlugin (java)

Issue:
Import/Export Settings
An option to export/import settings would be extremely helpful. Especially, when using custom messages for notifications. Something similar to how the Radius Markers plugin works.

![import-export-example](https://user-images.githubusercontent.com/87504521/215797380-3996fc8f-b2e5-44ee-a363-0efb47866884.png)


Pull request description:
feat: add config import and export commands
Closes #152


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 681651b..7bdec6b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,7 @@
 ## Unreleased
 
 - Major: Add Barbarian Assault high level gambling notifications. (#150)
+- Minor: Add plugin config export and import chat commands. (#155)
 - Minor: Make time units of advanced settings more obvious in the user interface. (#158)
 - Minor: Clean up setting tooltips that stretched too wide, making them difficult to read. (#151)
 - Minor: Add warning when in-game kill count chat spam filter is enabled. (#154)
diff --git a/README.md b/README.md
index edc8094..011fb8b 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,31 @@ Some notifiers require in-game settings to be configured to send chat messages u
 * Discord rich embed footers can be customized with user-specified text and image url
 * When network issues occur, Dink can make repeated attempts to send the webhook (with exponential backoff)
 
+## Chat Commands
+
+### Export Current Configuration via `::dinkexport`
+
+Dink allows you to export your current plugin configuration to the clipboard via the `::dinkexport` chat command.
+
+This export includes *every* setting from individual notifiers to the webhook URLs.
+
+You can share this produced JSON to friends who want to send similarly configured messages to the same webhook URLs.
+
+### Import Configuration via `::dinkimport`
+
+With the output of the above command (`::dinkexport`) copied to your clipboard, you can merge these settings with your own via the `::dinkimport` chat command.
+
+This import can replace all of your notifier settings.
+However, webhook URL lists and ignored RSNs will be combined, rather than outright replaced.
+If you would like all settings overwritten rather than merged during import, simply press the `Reset` button at the bottom of the plugin settings panel to clear out all settings (including URLs) before running `::dinkimport`.
+
+After an import, if the dink plugin settings panel was open, simply close and open it for the updated configuration to be reflected in the user interface.
+
+Note: There is no undo button for this command, so consider making a backup of your current Dink configuration by using the `::dinkexport` command explained above and saving that to a file on your computer.
+
+Warning: If you import override URLs for a notifier (that previously did not have any overrides), this will result in the plugin no longer sending messages from that notifier to your old primary URLs.
+As such, you can manually add your primary URLs to the newly populated override URL boxes so that notifications are still sent to the old primary URLs.
+
 ---
 
 ## Notifier Configuration
diff --git a/src/main/java/dinkplugin/DinkPlugin.java b/src/main/java/dinkplugin/DinkPlugin.java
index faf4236..3272825 100644
--- a/src/main/java/dinkplugin/DinkPlugin.java
+++ b/src/main/java/dinkplugin/DinkPlugin.java
@@ -19,6 +19,7 @@ import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.ChatMessageType;
 import net.runelite.api.events.ActorDeath;
 import net.runelite.api.events.ChatMessage;
+import net.runelite.api.events.CommandExecuted;
 import net.runelite.api.events.GameStateChanged;
 import net.runelite.api.events.GameTick;
 import net.runelite.api.events.InteractingChanged;
@@ -41,6 +42,7 @@ import net.runelite.client.util.ColorUtil;
 import net.runelite.client.util.Text;
 
 import javax.inject.Inject;
+import java.awt.Color;
 
 @Slf4j
 @PluginDescriptor(
@@ -97,6 +99,11 @@ public class DinkPlugin extends Plugin {
         return configManager.getConfig(DinkPluginConfig.class);
     }
 
+    @Subscribe
+    public void onCommandExecuted(CommandExecuted event) {
+        settingsManager.onCommand(event);
+    }
+
     @Subscribe
     public void onConfigChanged(ConfigChanged event) {
         settingsManager.onConfigChanged(event);
@@ -201,11 +208,19 @@ public class DinkPlugin extends Plugin {
         lootNotifier.onWidgetLoaded(event);
     }
 
+    public void addChatSuccess(String message) {
+        addChatMessage("Success", Utils.GREEN, message);
+    }
+
     public void addChatWarning(String message) {
+        addChatMessage("Warning", Utils.RED, message);
+    }
+
+    private void addChatMessage(String category, Color color, String message) {
         String formatted = String.format("[%s] %s: %s",
             ColorUtil.wrapWithColorTag(getName(), Utils.PINK),
-            "Warning",
-            ColorUtil.wrapWithColorTag(message, Utils.RED)
+            category,
+            ColorUtil.wrapWithColorTag(message, color)
         );
 
         chatManager.queue(
diff --git a/src/main/java/dinkplugin/SettingsManager.java b/src/main/java/dinkplugin/SettingsManager.java
index 424efaa..9b96ec0 100644
--- a/src/main/java/dinkplugin/SettingsManager.java
+++ b/src/main/java/dinkplugin/SettingsManager.java
@@ -1,5 +1,7 @@
 package dinkplugin;
 
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
 import dinkplugin.notifiers.CollectionNotifier;
 import dinkplugin.notifiers.CombatTaskNotifier;
 import dinkplugin.notifiers.KillCountNotifier;
@@ -11,32 +13,48 @@ import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
 import net.runelite.api.GameState;
 import net.runelite.api.Varbits;
+import net.runelite.api.events.CommandExecuted;
 import net.runelite.api.events.VarbitChanged;
 import net.runelite.client.callback.ClientThread;
+import net.runelite.client.config.ConfigManager;
 import net.runelite.client.events.ConfigChanged;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.Pair;
 import org.jetbrains.annotations.VisibleForTesting;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
+import java.lang.reflect.Type;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.HashSet;
-import java.util.regex.Pattern;
-import java.util.stream.Stream;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+import static dinkplugin.util.ConfigUtil.*;
 
 @Slf4j
 @Singleton
 @RequiredArgsConstructor(onConstructor_ = { @Inject })
 public class SettingsManager {
     private static final String CONFIG_GROUP = "dinkplugin";
-    private static final Pattern DELIM = Pattern.compile("[,;\\n]");
 
+    /**
+     * Maps our setting keys to their type for safe serialization & deserialization
+     */
+    private final Map<String, Type> configValueTypes = new HashMap<>();
     private final Collection<String> ignoredNames = new HashSet<>();
 
+    private final Gson gson;
     private final Client client;
     private final ClientThread clientThread;
     private final DinkPlugin plugin;
     private final DinkPluginConfig config;
+    private final ConfigManager configManager;
 
     /**
      * Check whether a username is not on the configured ignore list.
@@ -52,6 +70,17 @@ public class SettingsManager {
     @VisibleForTesting
     public void init() {
         setIgnoredNames(config.ignoredNames());
+        configManager.getConfigDescriptor(config).getItems()
+            .forEach(item -> configValueTypes.put(item.key(), item.getType()));
+    }
+
+    void onCommand(CommandExecuted event) {
+        String cmd = event.getCommand();
+        if ("dinkimport".equalsIgnoreCase(cmd)) {
+            importConfig();
+        } else if ("dinkexport".equalsIgnoreCase(cmd)) {
+            exportConfig();
+        }
     }
 
     void onConfigChanged(ConfigChanged event) {
@@ -134,13 +163,6 @@ public class SettingsManager {
         }
     }
 
-    private Stream<String> readDelimited(String value) {
-        if (value == null) return Stream.empty();
-        return DELIM.splitAsStream(value)
-            .map(String::trim)
-            .filter(StringUtils::isNotEmpty);
-    }
-
     @Synchronized
     private void setIgnoredNames(String configValue) {
         ignoredNames.clear();
@@ -153,23 +175,142 @@ public class SettingsManager {
         plugin.resetNotifiers();
     }
 
-    private static boolean isKillCountFilterInvalid(int varbitValue) {
-        // spam filter must be disabled for kill count chat message
-        return varbitValue > 0;
-    }
+    /**
+     * Exports the full Dink config to a JSON map, excluding empty lists,
+     * which is copied to the user's clipboard in string form
+     */
+    @Synchronized
+    private void exportConfig() {
+        String prefix = CONFIG_GROUP + '.';
+        Map<String, Object> configMap = configManager.getConfigurationKeys(prefix)
+            .stream()
+            .map(prop -> prop.substring(prefix.length()))
+            .map(key -> Pair.of(key, configValueTypes.get(key)))
+            .filter(pair -> pair.getValue() != null)
+            .map(pair -> Pair.of(pair.getKey(), configManager.getConfiguration(CONFIG_GROUP, pair.getKey(), pair.getValue())))
+            .filter(pair -> pair.getValue() != null)
+            .filter(pair -> {
+                // only serialize webhook urls if they are not blank
+                if (WEBHOOK_CONFIG_KEYS.contains(pair.getKey())) {
+                    Object value = pair.getValue();
+                    return value instanceof String && StringUtils.isNotBlank((String) value);
+                }
+
+                // always serialize everything else
+                return true;
+            })
+            .collect(Collectors.toMap(Pair::getKey, Pair::getValue));
 
-    private static boolean isCollectionLogInvalid(int varbitValue) {
-        // we require chat notification for collection log notifier
-        return varbitValue != 1 && varbitValue != 3;
+        Utils.copyToClipboard(gson.toJson(configMap))
+            .thenRun(() -> plugin.addChatSuccess("Copied current configuration to clipboard"))
+            .exceptionally(e -> {
+                plugin.addChatWarning("Failed to copy config to clipboard");
+                return null;
+            });
     }
 
-    private static boolean isRepeatPopupInvalid(int varbitValue) {
-        // we discourage repeat notifications for combat task notifier if unintentional
-        return varbitValue > 0;
+    /**
+     * Imports a Dink JSON config export from the user's clipboard
+     */
+    @Synchronized
+    private void importConfig() {
+        Utils.readClipboard()
+            .thenApplyAsync(json -> {
+                if (json == null || json.isEmpty()) {
+                    plugin.addChatWarning("Clipboard was empty");
+                    return null;
+                }
+
+                try {
+                    return gson.<Map<String, Object>>fromJson(json, new TypeToken<Map<String, Object>>() {}.getType());
+                } catch (Exception e) {
+                    String warning = "Failed to parse config from clipboard";
+                    log.warn(warning, e);
+                    plugin.addChatWarning(warning);
+                    return null;
+                }
+            })
+            .thenAcceptAsync(this::handleImport)
+            .exceptionally(e -> {
+                plugin.addChatWarning("Failed to read clipboard");
+                return null;
+            });
     }
 
-    private static boolean isPetLootInvalid(int varbitValue) {
-        // LOOT_DROP_NOTIFICATIONS and UNTRADEABLE_LOOT_DROPS must both be set to 1 for reliable pet name parsing
-        return varbitValue < 1;
+    @Synchronized
+    private void handleImport(Map<String, Object> map) {
+        if (map == null) return;
+
+        AtomicInteger numUpdated = new AtomicInteger();
+        Collection<String> mergedConfigs = new TreeSet<>();
+        map.forEach((key, rawValue) -> {
+            Type valueType = configValueTypes.get(key);
+            if (valueType == null) {
+                log.debug("Encountered unrecognized config mapping during import: {} = {}", key, rawValue);
+                return;
+            }
+
+            if (rawValue == null) {
+                log.debug("Encountered null value for config key: {}", key);
+                return;
+            }
+
+            Object value = convertTypeFromJson(valueType, rawValue);
+            if (value == null) {
+                log.debug("Encountered config value with incorrect type: {} = {}", key, rawValue);
+                return;
+            }
+
+            Object prevValue = configManager.getConfiguration(CONFIG_GROUP, key, valueType);
+            Object newValue;
+
+            if (WEBHOOK_CONFIG_KEYS.contains(key) || "ignoredNames".equals(key)) {
+                // special case: multi-line configs that should be merged (rather than replaced)
+                assert prevValue == null || prevValue instanceof String;
+                Collection<String> lines = readDelimited((String) prevValue).collect(Collectors.toCollection(LinkedHashSet::new));
+
+                int oldCount = lines.size();
+                assert value instanceof String;
+                long added = readDelimited((String) value)
+                    .map(lines::add)
+                    .filter(Boolean::booleanValue)
+                    .count();
+
+                if (added > 0) {
+                    newValue = String.join("\n", lines);
+
+                    if (oldCount > 0) {
+                        String displayName = "discordWebhook".equals(key) ? "primaryWebhook" : key;
+                        mergedConfigs.add(displayName);
+                    }
+                } else {
+                    newValue = null;
+                }
+            } else {
+                if (Objects.equals(value, prevValue)) {
+                    newValue = null;
+                } else {
+                    newValue = value;
+                }
+            }
+
+            if (newValue != null) {
+                configManager.setConfiguration(CONFIG_GROUP, key, newValue);
+                numUpdated.incrementAndGet();
+            }
+        });
+
+        plugin.addChatSuccess(
+            String.format(
+                "Updated %d config settings (from %d total specified in import). " +
+                    "Please close and open the plugin settings panel for these changes to be visually reflected.",
+                numUpdated.get(),
+                map.size()
+            )
+        );
+
+        if (!mergedConfigs.isEmpty()) {
+            plugin.addChatSuccess("The following settings were merged (rather than being overwritten): " + String.join(", ", mergedConfigs));
+        }
     }
 }
diff --git a/src/main/java/dinkplugin/util/ConfigUtil.java b/src/main/java/dinkplugin/util/ConfigUtil.java
new file mode 100644
index 0000000..006194e
--- /dev/null
+++ b/src/main/java/dinkplugin/util/ConfigUtil.java
@@ -0,0 +1,147 @@
+package dinkplugin.util;
+
+import com.google.common.collect.ImmutableSet;
+import lombok.experimental.UtilityClass;
+import net.runelite.client.util.ColorUtil;
+import org.apache.commons.lang3.StringUtils;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.awt.Color;
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
+
+@UtilityClass
+public class ConfigUtil {
+
+    private final Pattern DELIM = Pattern.compile("[,;\\n]");
+
+    /**
+     * Set of our config keys that correspond to webhook URL lists.
+     * <p>
+     * These are used in {@link dinkplugin.SettingsManager} for special logic
+     * to merge the previous value with the new value during config imports.
+     *
+     * @see dinkplugin.DinkPluginConfig
+     */
+    public final Collection<String> WEBHOOK_CONFIG_KEYS = ImmutableSet.of(
+        "discordWebhook",
+        "collectionWebhook",
+        "petWebhook",
+        "levelWebhook",
+        "lootWebhook",
+        "deathWebhook",
+        "slayerWebhook",
+        "questWebhook",
+        "clueWebhook",
+        "speedrunWebhook",
+        "killCountWebhook",
+        "combatTaskWebhook",
+        "diaryWebhook",
+        "gambleWebhook"
+    );
+
+    public Stream<String> readDelimited(String value) {
+        if (value == null) return Stream.empty();
+        return DELIM.splitAsStream(value)
+            .map(String::trim)
+            .filter(StringUtils::isNotEmpty);
+    }
+
+    @Nullable
+    public Object convertTypeFromJson(@NotNull Type type, @NotNull Object in) {
+        if (in instanceof Boolean)
+            return type == boolean.class || type == Boolean.class ? in : null;
+
+        if (in instanceof Number) {
+            Number n = (Number) in;
+
+            if (type == int.class || type == Integer.class)
+                return n.intValue();
+
+            if (type == long.class || type == Long.class)
+                return n.longValue();
+
+            if (type == float.class || type == Float.class)
+                return n.floatValue();
+
+            if (type == double.class || type == Double.class)
+                return n.doubleValue();
+
+            if (type == byte.class || type == Byte.class)
+                return n.byteValue();
+
+            if (type == short.class || type == Short.class)
+                return n.shortValue();
+
+            if (type == Instant.class)
+                return Instant.ofEpochMilli(n.longValue());
+
+            if (type == Duration.class)
+                return Duration.ofMillis(n.longValue());
+
+            return null;
+        }
+
+        if (in instanceof String) {
+            String s = (String) in;
+
+            if (type == String.class)
+                return s;
+
+            if (type == Color.class)
+                return ColorUtil.fromString(s);
+
+            if (type instanceof Class && ((Class<?>) type).isEnum()) {
+                try {
+                    // noinspection unchecked,rawtypes
+                    return Enum.valueOf((Class<? extends Enum>) type, s);
+                } catch (Exception e) {
+                    return null;
+                }
+            }
+
+            if (type == Instant.class) {
+                try {
+                    return Instant.parse(s);
+                } catch (Exception e) {
+                    return null;
+                }
+            }
+
+            if (type == Duration.class) {
+                try {
+                    return Duration.parse(s);
+                } catch (Exception e) {
+                    return null;
+                }
+            }
+        }
+
+        return null;
+    }
+
+    public boolean isKillCountFilterInvalid(int varbitValue) {
+        // spam filter must be disabled for kill count chat message
+        return varbitValue > 0;
+    }
+
+    public boolean isCollectionLogInvalid(int varbitValue) {
+        // we require chat notification for collection log notifier
+        return varbitValue != 1 && varbitValue != 3;
+    }
+
+    public boolean isRepeatPopupInvalid(int varbitValue) {
+        // we discourage repeat notifications for combat task notifier if unintentional
+        return varbitValue > 0;
+    }
+
+    public boolean isPetLootInvalid(int varbitValue) {
+        // LOOT_DROP_NOTIFICATIONS and UNTRADEABLE_LOOT_DROPS must both be set to 1 for reliable pet name parsing
+        return varbitValue < 1;
+    }
+}
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index 6ee9fe2..6d37656 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -3,6 +3,7 @@ package dinkplugin.util;
 import com.google.gson.Gson;
 import com.google.gson.reflect.TypeToken;
 import lombok.experimental.UtilityClass;
+import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
 import net.runelite.api.vars.AccountType;
 import net.runelite.api.widgets.Widget;
@@ -19,7 +20,12 @@ import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
 import javax.imageio.ImageIO;
+import javax.swing.SwingUtilities;
 import java.awt.Color;
+import java.awt.Toolkit;
+import java.awt.datatransfer.Clipboard;
+import java.awt.datatransfer.DataFlavor;
+import java.awt.datatransfer.StringSelection;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
@@ -27,11 +33,13 @@ import java.io.Reader;
 import java.util.concurrent.CompletableFuture;
 import java.util.function.Function;
 
+@Slf4j
 @UtilityClass
 public class Utils {
 
     public static final String WIKI_IMG_BASE_URL = "https://oldschool.runescape.wiki/images/";
 
+    public final Color GREEN = ColorUtil.fromHex("006c4c"); // dark shade of PINK in CIELCh_uv color space
     public final Color PINK = ColorUtil.fromHex("#f40098"); // analogous to RED in CIELCh_uv color space
     public final Color RED = ColorUtil.fromHex("#ca2a2d"); // red used in pajaW
 
@@ -86,6 +94,37 @@ public class Utils {
         });
     }
 
+    public CompletableFuture<String> readClipboard() {
+        CompletableFuture<String> future = new CompletableFuture<>();
+        SwingUtilities.invokeLater(() -> {
+            try {
+                Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+                String data = (String) clipboard.getData(DataFlavor.stringFlavor);
+                future.complete(data);
+            } catch (Exception e) {
+                log.warn("Failed to read from clipboard", e);
+                future.completeExceptionally(e);
+            }
+        });
+        return future;
+    }
+
+    public CompletableFuture<Void> copyToClipboard(String text) {
+        CompletableFuture<Void> future = new CompletableFuture<>();
+        SwingUtilities.invokeLater(() -> {
+            try {
+                Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+                StringSelection content = new StringSelection(text);
+                clipboard.setContents(content, content);
+                future.complete(null);
+            } catch (Exception e) {
+                log.warn("Failed to copy to clipboard", e);
+                future.completeExceptionally(e);
+            }
+        });
+        return future;
+    }
+
     public <T> CompletableFuture<T> readJson(@NotNull OkHttpClient httpClient, @NotNull Gson gson, @NotNull String url, @NotNull TypeToken<T> type) {
         return readUrl(httpClient, url, reader -> gson.fromJson(reader, type.getType()));
     }


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
index 34b07ac..67566f1 100644
--- a/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/MockedNotifierTest.java
@@ -18,6 +18,8 @@ import net.runelite.api.WorldType;
 import net.runelite.api.vars.AccountType;
 import net.runelite.client.callback.ClientThread;
 import net.runelite.client.chat.ChatMessageManager;
+import net.runelite.client.config.ConfigDescriptor;
+import net.runelite.client.config.ConfigManager;
 import net.runelite.client.game.ItemManager;
 import net.runelite.client.ui.DrawManager;
 import net.runelite.http.api.RuneLiteAPI;
@@ -26,6 +28,7 @@ import org.mockito.Mock;
 import org.mockito.Mockito;
 
 import java.awt.Image;
+import java.util.Collections;
 import java.util.EnumSet;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.function.Consumer;
@@ -74,7 +77,10 @@ abstract class MockedNotifierTest extends MockedTestBase {
     protected DinkPlugin plugin = Mockito.spy(DinkPlugin.class);
 
     @Bind
-    protected SettingsManager settingsManager = Mockito.spy(new SettingsManager(client, clientThread, plugin, config));
+    protected ConfigManager configManager = Mockito.mock(ConfigManager.class);
+
+    @Bind
+    protected SettingsManager settingsManager = Mockito.spy(new SettingsManager(gson, client, clientThread, plugin, config, configManager));
 
     @Bind
     protected DiscordMessageHandler messageHandler = Mockito.spy(new DiscordMessageHandler(gson, client, drawManager, httpClient, config, executor));
@@ -96,6 +102,10 @@ abstract class MockedNotifierTest extends MockedTestBase {
             return null;
         }).when(drawManager).requestNextFrameListener(any());
 
+        ConfigDescriptor configDescriptor = mock(ConfigDescriptor.class);
+        when(configManager.getConfigDescriptor(any())).thenReturn(configDescriptor);
+        when(configDescriptor.getItems()).thenReturn(Collections.emptyList());
+
         // init config mocks
         when(config.primaryWebhook()).thenReturn(PRIMARY_WEBHOOK_URL);
         when(config.maxRetries()).thenReturn(0);