Repository: python-discord/bot

Issue:
tag-search command to search tags via their contents instead of names.
Currently, it can be difficult to find the specific command for a tag even if you know it exists. A command to allow you to search through tag bodies would help with finding the right tags. For example doing `!tag-search backtick` would return `!code-block` (and any other tags that include the word backtick).


Discussion:
This is an interesting idea, and this is very feasible since we already fetch all tags into a dictionary of `Dict[str, str]` for `tag['title'].lower(): tag` - and so we can make a command to search on the contents of the tag instead of just purely the name of the tag ( incase we forgot the name! )
I will be working on this!

Resolving patch:
diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py
--- a/bot/cogs/tags.py
+++ b/bot/cogs/tags.py
@@ -1,7 +1,7 @@
 import logging
 import re
 import time
-from typing import Dict, List, Optional
+from typing import Callable, Dict, Iterable, List, Optional
 
 from discord import Colour, Embed
 from discord.ext.commands import Cog, Context, group
@@ -86,11 +86,80 @@ async def _get_tag(self, tag_name: str) -> list:
             return self._get_suggestions(tag_name)
         return found
 
+    async def _get_tags_via_content(self, check: Callable[[Iterable], bool], keywords: str) -> list:
+        """
+        Search for tags via contents.
+
+        `predicate` will be the built-in any, all, or a custom callable. Must return a bool.
+        """
+        await self._get_tags()
+
+        keywords_processed: List[str] = []
+        for keyword in keywords.split(','):
+            keyword_sanitized = keyword.strip().casefold()
+            if not keyword_sanitized:
+                # this happens when there are leading / trailing / consecutive comma.
+                continue
+            keywords_processed.append(keyword_sanitized)
+
+        if not keywords_processed:
+            # after sanitizing, we can end up with an empty list, for example when keywords is ','
+            # in that case, we simply want to search for such keywords directly instead.
+            keywords_processed = [keywords]
+
+        matching_tags = []
+        for tag in self._cache.values():
+            if check(query in tag['embed']['description'].casefold() for query in keywords_processed):
+                matching_tags.append(tag)
+
+        return matching_tags
+
+    async def _send_matching_tags(self, ctx: Context, keywords: str, matching_tags: list) -> None:
+        """Send the result of matching tags to user."""
+        if not matching_tags:
+            pass
+        elif len(matching_tags) == 1:
+            await ctx.send(embed=Embed().from_dict(matching_tags[0]['embed']))
+        else:
+            is_plural = keywords.strip().count(' ') > 0 or keywords.strip().count(',') > 0
+            embed = Embed(
+                title=f"Here are the tags containing the given keyword{'s' * is_plural}:",
+                description='\n'.join(tag['title'] for tag in matching_tags[:10])
+            )
+            await LinePaginator.paginate(
+                sorted(f"**»**   {tag['title']}" for tag in matching_tags),
+                ctx,
+                embed,
+                footer_text="To show a tag, type !tags <tagname>.",
+                empty=False,
+                max_lines=15
+            )
+
     @group(name='tags', aliases=('tag', 't'), invoke_without_command=True)
     async def tags_group(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
         """Show all known tags, a single tag, or run a subcommand."""
         await ctx.invoke(self.get_command, tag_name=tag_name)
 
+    @tags_group.group(name='search', invoke_without_command=True)
+    async def search_tag_content(self, ctx: Context, *, keywords: str) -> None:
+        """
+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
+
+        Only search for tags that has ALL the keywords.
+        """
+        matching_tags = await self._get_tags_via_content(all, keywords)
+        await self._send_matching_tags(ctx, keywords, matching_tags)
+
+    @search_tag_content.command(name='any')
+    async def search_tag_content_any_keyword(self, ctx: Context, *, keywords: Optional[str] = None) -> None:
+        """
+        Search inside tags' contents for tags. Allow searching for multiple keywords separated by comma.
+
+        Search for tags that has ANY of the keywords.
+        """
+        matching_tags = await self._get_tags_via_content(any, keywords or 'any')
+        await self._send_matching_tags(ctx, keywords, matching_tags)
+
     @tags_group.command(name='get', aliases=('show', 'g'))
     async def get_command(self, ctx: Context, *, tag_name: TagNameConverter = None) -> None:
         """Get a specified tag, or a list of all tags if no tag is specified."""


Test patch: