Repository: pajlads/DinkPlugin
Pull request #128 (2023-01-15 22:00:26)

Added ItemSearcher that can find item urls based on name.
I highly suspect the code needs some more work. But this feature simply adds the item as a thumbnail to the collection log notifier. When something goes wrong it will still use the boring collection log book as fallback thumb.
![image](https://user-images.githubusercontent.com/17124771/212569329-81110d3a-c863-46e7-887b-48358ca38f02.png)

Also I won't be able to fix any comments till probably next weekend. So feel free to improve this PR 😋

Felanbird: `- Minor: Made Collection Log notifications display the icon of the obtained item. (#128)`

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8178614..095943c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 ## Unreleased
 
+- Minor: Made Collection Log notifications display the icon of the obtained item. (#128)
 - Minor: Made player names in Discord notifications link to a player profile page. This feature can be customized to different providers such as WiseOldMan & CrystalMathLabs in the Advanced menu. (#126)
 - Minor: Add setting to skip virtual level notifications. (#122)
 - Minor: Update the README to be less confusing to users. (#123)
diff --git a/README.md b/README.md
index 4f21890..1222dfa 100644
--- a/README.md
+++ b/README.md
@@ -116,7 +116,9 @@ The examples below omit `embeds` and `playerName` keys because they are always t
 {
   "content": "%USERNAME% has added %ITEM% to their collection",
   "extra": {
-    "itemName": "%ITEM%"
+    "itemName": "Zamorak chaps",
+    "itemId": 10372,
+    "price": 500812
   },
   "type": "COLLECTION"
 }
diff --git a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
index ed3ab9c..b244a31 100644
--- a/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
+++ b/src/main/java/dinkplugin/notifiers/CollectionNotifier.java
@@ -2,11 +2,15 @@ package dinkplugin.notifiers;
 
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
+import dinkplugin.util.ItemSearcher;
+import dinkplugin.util.ItemUtils;
 import dinkplugin.util.Utils;
 import dinkplugin.notifiers.data.CollectionNotificationData;
+import net.runelite.client.game.ItemManager;
 import org.apache.commons.lang3.StringUtils;
 import org.jetbrains.annotations.VisibleForTesting;
 
+import javax.inject.Inject;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -15,6 +19,12 @@ public class CollectionNotifier extends BaseNotifier {
     static final Pattern COLLECTION_LOG_REGEX = Pattern.compile("New item added to your collection log: (?<itemName>(.*))");
     public static final String ADDITION_WARNING = "Collection notifier will not fire unless you enable the game setting: Collection log - New addition notification";
 
+    @Inject
+    private ItemManager itemManager;
+
+    @Inject
+    private ItemSearcher itemSearcher;
+
     @Override
     public boolean isEnabled() {
         return config.notifyCollectionLog() && super.isEnabled();
@@ -41,9 +51,12 @@ public class CollectionNotifier extends BaseNotifier {
             new String[] { Utils.getPlayerName(client), itemName }
         );
 
+        Integer itemId = itemSearcher.findItemId(itemName);
+        Long price = itemId != null ? ItemUtils.getPrice(itemManager, itemId) : null;
         createMessage(config.collectionSendImage(), NotificationBody.builder()
             .text(notifyMessage)
-            .extra(new CollectionNotificationData(itemName))
+            .thumbnailUrl(itemId != null ? ItemUtils.getItemImageUrl(itemId) : null)
+            .extra(new CollectionNotificationData(itemName, itemId, price))
             .type(NotificationType.COLLECTION)
             .build());
     }
diff --git a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
index 7912178..6abeb93 100644
--- a/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
+++ b/src/main/java/dinkplugin/notifiers/data/CollectionNotificationData.java
@@ -2,9 +2,20 @@ package dinkplugin.notifiers.data;
 
 import lombok.EqualsAndHashCode;
 import lombok.Value;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
 @Value
 @EqualsAndHashCode(callSuper = false)
 public class CollectionNotificationData extends NotificationData {
+
+    @NotNull
     String itemName;
+
+    @Nullable
+    Integer itemId;
+
+    @Nullable
+    Long price;
+
 }
diff --git a/src/main/java/dinkplugin/util/ItemSearcher.java b/src/main/java/dinkplugin/util/ItemSearcher.java
new file mode 100644
index 0000000..5d7b8d0
--- /dev/null
+++ b/src/main/java/dinkplugin/util/ItemSearcher.java
@@ -0,0 +1,105 @@
+package dinkplugin.util;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.OkHttpClient;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * This thread-safe singleton holds a mapping of item names to their item id, using the RuneLite API.
+ * <p>
+ * Unlike {@link net.runelite.client.game.ItemManager#search(String)}, this mapping supports untradable items.
+ */
+@Slf4j
+@Singleton
+public class ItemSearcher {
+    private final Map<String, Integer> itemIdByName = Collections.synchronizedMap(new HashMap<>(16384));
+    private @Inject OkHttpClient httpClient;
+    private @Inject Gson gson;
+
+    /**
+     * @param name the exact in-game name of an item
+     * @return the id associated with the item name, or null if not found
+     */
+    @Nullable
+    public Integer findItemId(@NotNull String name) {
+        return itemIdByName.get(name);
+    }
+
+    /**
+     * Begins the initialization process for {@link #itemIdByName}
+     * by querying item names and noted item ids from the RuneLite API,
+     * before passing them to {@link #populate(Map, Set)}
+     *
+     * @implNote This operation does not block the current thread,
+     * by utilizing OkHttp's thread pool and Java's Fork-Join common pool.
+     */
+    @Inject
+    void init() {
+        queryNamesById()
+            .thenAcceptBothAsync(
+                queryNotedItemIds().exceptionally(e -> {
+                    log.error("Failed to read noted items", e);
+                    return Collections.emptySet();
+                }),
+                this::populate
+            )
+            .exceptionally(e -> {
+                log.error("Failed to read item names", e);
+                return null;
+            });
+    }
+
+    /**
+     * Populates {@link #itemIdByName} with the inverted mappings of {@code namesById},
+     * while skipping noted items specified in {@code notedIds}.
+     *
+     * @param namesById a mapping of item id's to the corresponding in-game name
+     * @param notedIds  the id's of noted items
+     * @implNote When multiple non-noted item id's have the same in-game name, only the earliest id is saved
+     */
+    @VisibleForTesting
+    void populate(@NotNull Map<Integer, String> namesById, @NotNull Set<Integer> notedIds) {
+        namesById.forEach((id, name) -> {
+            if (!notedIds.contains(id))
+                itemIdByName.putIfAbsent(name, id);
+        });
+
+        log.debug("Completed initialization of item cache with {} entries", itemIdByName.size());
+    }
+
+    /**
+     * @return a mapping of item ids to their in-game names, provided by the RuneLite API
+     */
+    private CompletableFuture<Map<Integer, String>> queryNamesById() {
+        return queryCache("names.json", new TypeToken<Map<Integer, String>>() {});
+    }
+
+    /**
+     * @return a set of id's of noted items, provided by the RuneLite API
+     */
+    private CompletableFuture<Set<Integer>> queryNotedItemIds() {
+        return queryCache("notes.json", new TypeToken<Map<Integer, Integer>>() {})
+            .thenApply(Map::keySet);
+    }
+
+    /**
+     * @param fileName the name of the file to query from RuneLite's cache
+     * @param type     a type token that indicates how the json response should be parsed
+     * @return the transformed cache response, wrapped in a future
+     */
+    private <T> CompletableFuture<T> queryCache(@NotNull String fileName, @NotNull TypeToken<T> type) {
+        return Utils.readJson(httpClient, gson, ItemUtils.ITEM_CACHE_BASE_URL + fileName, type);
+    }
+}
diff --git a/src/main/java/dinkplugin/util/ItemUtils.java b/src/main/java/dinkplugin/util/ItemUtils.java
index e52acf2..3362362 100644
--- a/src/main/java/dinkplugin/util/ItemUtils.java
+++ b/src/main/java/dinkplugin/util/ItemUtils.java
@@ -37,6 +37,8 @@ import static net.runelite.api.ItemID.TWISTED_STEEL_TROPHY;
 @UtilityClass
 public class ItemUtils {
 
+    final String ITEM_CACHE_BASE_URL = "https://static.runelite.net/cache/item/";
+
     private final Set<Integer> NEVER_KEPT_ITEMS = ImmutableSet.of(
         CLUE_BOX, LOOTING_BAG,
         AMULET_OF_THE_DAMNED, AMULET_OF_THE_DAMNED_FULL,
@@ -102,7 +104,7 @@ public class ItemUtils {
     }
 
     public String getItemImageUrl(int itemId) {
-        return "https://static.runelite.net/cache/item/icon/" + itemId + ".png";
+        return ITEM_CACHE_BASE_URL + "icon/" + itemId + ".png";
     }
 
     public String getNpcImageUrl(int npcId) {
diff --git a/src/main/java/dinkplugin/util/Utils.java b/src/main/java/dinkplugin/util/Utils.java
index c0f2191..d6cefcf 100644
--- a/src/main/java/dinkplugin/util/Utils.java
+++ b/src/main/java/dinkplugin/util/Utils.java
@@ -1,13 +1,20 @@
 package dinkplugin.util;
 
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
 import lombok.experimental.UtilityClass;
 import net.runelite.api.Client;
 import net.runelite.api.vars.AccountType;
 import net.runelite.api.widgets.Widget;
 import net.runelite.api.widgets.WidgetInfo;
 import net.runelite.client.util.ColorUtil;
+import okhttp3.Call;
+import okhttp3.Callback;
 import okhttp3.MediaType;
 import okhttp3.MultipartBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.Response;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
@@ -16,6 +23,9 @@ import java.awt.Color;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.io.Reader;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
 
 @UtilityClass
 public class Utils {
@@ -65,4 +75,32 @@ public class Utils {
         });
     }
 
+    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()));
+    }
+
+    public <T> CompletableFuture<T> readUrl(@NotNull OkHttpClient httpClient, @NotNull String url, @NotNull Function<Reader, T> transformer) {
+        CompletableFuture<T> future = new CompletableFuture<>();
+        Request request = new Request.Builder().url(url).build();
+        httpClient.newCall(request).enqueue(new Callback() {
+            @Override
+            public void onFailure(@NotNull Call call, @NotNull IOException e) {
+                future.completeExceptionally(e);
+            }
+
+            @Override
+            public void onResponse(@NotNull Call call, @NotNull Response response) {
+                assert response.body() != null;
+                try (Reader reader = response.body().charStream()) {
+                    future.complete(transformer.apply(reader));
+                } catch (Exception e) {
+                    future.completeExceptionally(e);
+                } finally {
+                    response.close();
+                }
+            }
+        });
+        return future;
+    }
+
 }


Test patch:
diff --git a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
index d945cb8..02d76dd 100644
--- a/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
+++ b/src/test/java/dinkplugin/notifiers/CollectionNotifierTest.java
@@ -4,9 +4,12 @@ import com.google.inject.testing.fieldbinder.Bind;
 import dinkplugin.message.NotificationBody;
 import dinkplugin.message.NotificationType;
 import dinkplugin.notifiers.data.CollectionNotificationData;
+import dinkplugin.util.ItemSearcher;
+import net.runelite.api.ItemID;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.InjectMocks;
+import org.mockito.Mock;
 
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyBoolean;
@@ -20,6 +23,10 @@ class CollectionNotifierTest extends MockedNotifierTest {
     @InjectMocks
     CollectionNotifier notifier;
 
+    @Bind
+    @Mock
+    ItemSearcher itemSearcher;
+
     @Override
     @BeforeEach
     protected void setUp() {
@@ -34,6 +41,9 @@ class CollectionNotifierTest extends MockedNotifierTest {
     @Test
     void testNotify() {
         String item = "Seercull";
+        int price = 23_000;
+        when(itemSearcher.findItemId(item)).thenReturn(ItemID.SEERCULL);
+        when(itemManager.getItemPrice(ItemID.SEERCULL)).thenReturn(price);
 
         // send fake message
         notifier.onChatMessage("New item added to your collection log: " + item);
@@ -44,7 +54,7 @@ class CollectionNotifierTest extends MockedNotifierTest {
             false,
             NotificationBody.builder()
                 .text(String.format("%s has added %s to their collection", PLAYER_NAME, item))
-                .extra(new CollectionNotificationData(item))
+                .extra(new CollectionNotificationData(item, ItemID.SEERCULL, (long) price))
                 .type(NotificationType.COLLECTION)
                 .build()
         );
diff --git a/src/test/java/dinkplugin/util/ItemSearcherTest.java b/src/test/java/dinkplugin/util/ItemSearcherTest.java
new file mode 100644
index 0000000..4c01651
--- /dev/null
+++ b/src/test/java/dinkplugin/util/ItemSearcherTest.java
@@ -0,0 +1,99 @@
+package dinkplugin.util;
+
+import com.google.gson.Gson;
+import com.google.inject.testing.fieldbinder.Bind;
+import dinkplugin.MockedTestBase;
+import net.runelite.api.ItemID;
+import net.runelite.http.api.RuneLiteAPI;
+import okhttp3.OkHttpClient;
+import org.junit.jupiter.api.Test;
+import org.mockito.Spy;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+class ItemSearcherTest extends MockedTestBase {
+
+    @Bind
+    private final Gson gson = RuneLiteAPI.GSON;
+
+    @Bind
+    private final OkHttpClient httpClient = new OkHttpClient();
+
+    @Spy
+    @Bind
+    private ItemSearcher itemSearcher;
+
+    @Test
+    void test() {
+        // Wait for http calls to complete
+        verify(itemSearcher, timeout(30_000).atLeastOnce()).populate(any(), any());
+
+        // Ensure correct item mappings are populated
+        assertEquals(ItemID.JAR_OF_MIASMA, itemSearcher.findItemId("Jar of miasma"));
+        assertEquals(ItemID.UNSIRED, itemSearcher.findItemId("Unsired"));
+
+        assertEquals(ItemID.ALCHEMICAL_HYDRA_HEADS, itemSearcher.findItemId("Alchemical hydra heads"));
+
+        assertEquals(ItemID.AHRIMS_STAFF, itemSearcher.findItemId("Ahrim's staff"));
+
+        assertEquals(ItemID.BRYOPHYTAS_ESSENCE, itemSearcher.findItemId("Bryophyta's essence"));
+
+        assertEquals(ItemID.DRAGON_2H_SWORD, itemSearcher.findItemId("Dragon 2h sword"));
+        assertEquals(ItemID.DRAGON_LEGSSKIRT_ORNAMENT_KIT, itemSearcher.findItemId("Dragon legs/skirt ornament kit"));
+
+        assertEquals(ItemID.PET_DAGANNOTH_PRIME, itemSearcher.findItemId("Pet dagannoth prime"));
+        assertEquals(ItemID.PET_KRIL_TSUTSAROTH, itemSearcher.findItemId("Pet k'ril tsutsaroth"));
+        assertEquals(ItemID.HELLPUPPY, itemSearcher.findItemId("Hellpuppy"));
+        assertEquals(ItemID.BABY_MOLE, itemSearcher.findItemId("Baby mole"));
+        assertEquals(ItemID.SCORPIAS_OFFSPRING, itemSearcher.findItemId("Scorpia's offspring"));
+        assertEquals(ItemID.VETION_JR, itemSearcher.findItemId("Vet'ion jr."));
+
+        assertEquals(ItemID.FEDORA, itemSearcher.findItemId("Fedora"));
+
+        assertEquals(ItemID.SEERCULL, itemSearcher.findItemId("Seercull"));
+
+        assertEquals(ItemID.TZREKJAD, itemSearcher.findItemId("Tzrek-jad"));
+        assertEquals(ItemID.JALNIBREK, itemSearcher.findItemId("Jal-nib-rek"));
+        assertEquals(ItemID.TOKTZKETXIL, itemSearcher.findItemId("Toktz-ket-xil"));
+
+        assertEquals(ItemID.ENHANCED_CRYSTAL_WEAPON_SEED, itemSearcher.findItemId("Enhanced crystal weapon seed"));
+
+        assertEquals(ItemID.GODSWORD_SHARD_2, itemSearcher.findItemId("Godsword shard 2"));
+
+        assertEquals(ItemID.KQ_HEAD, itemSearcher.findItemId("Kq head"));
+
+        assertEquals(ItemID.TORVA_PLATELEGS_DAMAGED, itemSearcher.findItemId("Torva platelegs (damaged)"));
+
+        assertEquals(ItemID.GIANT_EGG_SACFULL, itemSearcher.findItemId("Giant egg sac(full)"));
+
+        assertEquals(ItemID.TOME_OF_WATER_EMPTY, itemSearcher.findItemId("Tome of water (empty)"));
+
+        assertEquals(ItemID.ZULANDRA_TELEPORT, itemSearcher.findItemId("Zul-andra teleport"));
+
+        assertEquals(ItemID.SCYTHE_OF_VITUR_UNCHARGED, itemSearcher.findItemId("Scythe of vitur (uncharged)"));
+
+        assertEquals(ItemID.ELIDINIS_WARD, itemSearcher.findItemId("Elidinis' ward"));
+
+        assertEquals(ItemID.ICTHLARINS_SHROUD_TIER_2, itemSearcher.findItemId("Icthlarin's shroud (tier 2)"));
+
+        assertEquals(ItemID.BREACH_OF_THE_SCARAB, itemSearcher.findItemId("Breach of the scarab"));
+
+        assertEquals(ItemID.MENAPHITE_ORNAMENT_KIT, itemSearcher.findItemId("Menaphite ornament kit"));
+
+        assertEquals(ItemID.AMULET_OF_DEFENCE_T, itemSearcher.findItemId("Amulet of defence (t)"));
+
+        assertEquals(ItemID.RUNE_SCIMITAR_ORNAMENT_KIT_SARADOMIN, itemSearcher.findItemId("Rune scimitar ornament kit (saradomin)"));
+
+        assertEquals(ItemID.BLACK_PLATEBODY_H2, itemSearcher.findItemId("Black platebody (h2)"));
+
+        assertEquals(ItemID.RING_OF_3RD_AGE, itemSearcher.findItemId("Ring of 3rd age"));
+
+        assertEquals(ItemID.OBSIDIAN_CAPE_R, itemSearcher.findItemId("Obsidian cape (r)"));
+
+        assertEquals(ItemID.CHARGE_DRAGONSTONE_JEWELLERY_SCROLL, itemSearcher.findItemId("Charge dragonstone jewellery scroll"));
+    }
+
+}