Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expand the !wiki command to enable searching the wiki. #11

Merged
merged 1 commit into from
Oct 31, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions src/main/java/org/javacord/bot/commands/WikiCommand.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,165 @@

import de.btobastian.sdcf4j.Command;
import de.btobastian.sdcf4j.CommandExecutor;
import org.javacord.api.DiscordApi;
import org.javacord.api.entity.channel.TextChannel;
import org.javacord.api.entity.message.embed.EmbedBuilder;
import org.javacord.api.util.logging.ExceptionLogger;
import org.javacord.bot.Constants;
import org.javacord.bot.util.wiki.parser.WikiPage;
import org.javacord.bot.util.wiki.parser.WikiParser;

import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
* The !wiki command which is used to link to Javacord's wiki.
*/
public class WikiCommand implements CommandExecutor {

private static final Pattern HTML_TAG = Pattern.compile("<[^>]++>");

/**
* Executes the {@code !wiki} command.
*
* @param api The Discord api.
* @param channel The channel where the command was issued.
* @param args The command's arguments.
* @throws IOException If the connection to the wiki failed.
*/
@Command(aliases = {"!wiki"}, async = true)
public void onCommand(TextChannel channel) {
EmbedBuilder embed = new EmbedBuilder()
.setTitle("Javacord Wiki")
.setDescription("https://javacord.org/wiki")
.setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png")
.setColor(Constants.JAVACORD_ORANGE);
channel.sendMessage(embed).join();
public void onCommand(DiscordApi api, TextChannel channel, String[] args) throws IOException {
try {
if (args.length == 0) { // Just an overview
EmbedBuilder embed = new EmbedBuilder()
.setTitle("Javacord Wiki")
.setDescription("The [Javacord Wiki](" + WikiParser.BASE_URL + "/wiki) is an excellent "
+ "resource to get you started with Javacord.\n")
.addInlineField("Hint", "You can search the wiki using `!wiki [title|full] <search>")
.setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png")
.setColor(Constants.JAVACORD_ORANGE);
channel.sendMessage(embed).join();
} else {
EmbedBuilder embed = new EmbedBuilder()
.setThumbnail(getClass().getClassLoader().getResourceAsStream("javacord3_icon.png"), "png")
.setColor(Constants.JAVACORD_ORANGE);
String searchString = String.join(" ", Arrays.copyOfRange(args, 1, args.length)).toLowerCase();
switch (args[0]) {
case "page":
case "p":
case "title":
case "t":
populatePages(api, embed, titleOnly(searchString));
break;
case "full":
case "f":
case "content":
case "c":
populatePages(api, embed, fullSearch(searchString));
break;
default:
searchString = String.join(" ", Arrays.copyOfRange(args, 0, args.length)).toLowerCase();
populatePages(api, embed, defaultSearch(searchString));
}
channel.sendMessage(embed).join();
}
} catch (Throwable t) {
channel.sendMessage("Something went wrong: ```" + ExceptionLogger.unwrapThrowable(t).getMessage() + "```")
.join();
// Throw the caught exception again. The sdcf4j will log it.
throw t;
}
}

private Predicate<WikiPage> defaultSearch(String searchString) {
return titleOnly(searchString).or(keywordsOnly(searchString));
}

private Predicate<WikiPage> fullSearch(String searchString) {
Saladoc marked this conversation as resolved.
Show resolved Hide resolved
return titleOnly(searchString).or(keywordsOnly(searchString)).or(contentOnly(searchString));
Vampire marked this conversation as resolved.
Show resolved Hide resolved
}

private Predicate<WikiPage> titleOnly(String searchString) {
return p -> p.getTitle().toLowerCase().contains(searchString);
Vampire marked this conversation as resolved.
Show resolved Hide resolved
}

private Predicate<WikiPage> keywordsOnly(String searchString) {
return p -> Arrays.stream(p.getKeywords())
.map(String::toLowerCase)
.anyMatch(k -> k.contains(searchString));
}

private Predicate<WikiPage> contentOnly(String searchString) {
return p -> p.getContent().toLowerCase().contains(searchString);
}


private void populatePages(DiscordApi api, EmbedBuilder embed, Predicate<WikiPage> criteria) throws IOException {
List<WikiPage> pages;

pages = new WikiParser(api)
.getPagesBlocking().stream()
.filter(criteria)
.sorted()
.collect(Collectors.toList());

if (pages.isEmpty()) {
embed.setTitle("Javacord Wiki");
embed.setUrl(WikiParser.BASE_URL + "/wiki/");
embed.setDescription("No pages found. Maybe try another search.");
embed.addField("Standard Search", "Use `!wiki <search>` to search page titles and keywords.");
embed.addField("Title Search", "Use `!wiki [page|p|title|t] <search>` to exclusively search page titles.");
embed.addField("Full Search", "Use `!wiki [full|f|content|c] <search>` to perform a full search.");
} else if (pages.size() == 1) {
WikiPage page = pages.get(0);
displayPagePreview(embed, page);
} else {
displayPageList(embed, pages);
}
}

private void displayPagePreview(EmbedBuilder embed, WikiPage page) {
embed.setTitle("Javacord Wiki");
String cleanedDescription = HTML_TAG.matcher(page.getContent()).replaceAll("").trim();
int length = 0;
int sentences = 0;
while (length < 600 && sentences < 3) {
int tmpLength = cleanedDescription.indexOf(". ", length);
length = (tmpLength > length) ? tmpLength : cleanedDescription.indexOf(".\n");

sentences++;
}
StringBuilder description = new StringBuilder()
.append(String.format("**[%s](%s)**\n\n", page.getTitle(), WikiParser.BASE_URL + page.getUrl()))
.append(cleanedDescription, 0, length + 1);
if (length < cleanedDescription.length()) {
description.append("\n\n[*view full page*](").append(WikiParser.BASE_URL).append(page.getUrl()).append(")");
}
embed.setDescription(description.toString());
}

private void displayPageList(EmbedBuilder embed, List<WikiPage> pages) {
embed.setTitle("Javacord Wiki");
embed.setUrl(WikiParser.BASE_URL + "/wiki/");

StringBuilder builder = new StringBuilder();
int counter = 0;
for (WikiPage page : pages) {
String pageLink = "• " + page.asMarkdownLink();
if (builder.length() + pageLink.length() > 1900) { // Prevent hitting the description size limit
break;
}
builder.append(pageLink).append("\n");
counter++;
}
if (pages.size() > counter) {
builder.append("and ").append(pages.size() - counter).append(" more ...");
}
embed.setDescription(builder.toString());
}

}
78 changes: 78 additions & 0 deletions src/main/java/org/javacord/bot/util/wiki/parser/WikiPage.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package org.javacord.bot.util.wiki.parser;

/**
* A class representing a page on the wiki.
*/
public class WikiPage implements Comparable<WikiPage> {

private final String title;
private final String[] keywords;
private final String url;
private final String content;

/**
* Creates a new wiki page.
*
* @param title The title of the page.
* @param keywords The keywords the page is tagged with.
* @param url The URL of the page, relative to the wiki's base URL.
* @param content The content of the page.
*/
public WikiPage(String title, String[] keywords, String url, String content) {
this.title = title;
this.keywords = keywords;
this.url = url;
this.content = content;
}

/**
* Gets the title.
*
* @return The title of the page.
*/
public String getTitle() {
return title;
}

/**
* Gets the keywords.
*
* @return The keywords for the page.
*/
public String[] getKeywords() {
return keywords;
}

/**
* Gets the relative URL.
*
* @return The page URL.
*/
public String getUrl() {
return url;
}

/**
* Gets the content.
*
* @return The content of the page.
*/
public String getContent() {
return content;
}

/**
* Gets a markdown-formatted link to the page.
*
* @return The markdown for a link to the page.
*/
public String asMarkdownLink() {
return String.format("[%s](%s)", title, WikiParser.BASE_URL + url);
}

@Override
public int compareTo(WikiPage that) {
return this.title.compareTo(that.title);
}

}
114 changes: 114 additions & 0 deletions src/main/java/org/javacord/bot/util/wiki/parser/WikiParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package org.javacord.bot.util.wiki.parser;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.javacord.api.DiscordApi;

import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;

/**
* A parser for the Javacord wiki.
*/
public class WikiParser {

public static final String API_URL = "https://javacord.org/api/wiki.json";
public static final String BASE_URL = "https://javacord.org"; // the /wiki/ part of the url will be returned by the API

private static final OkHttpClient client = new OkHttpClient();
private static final ObjectMapper mapper = new ObjectMapper();

private final DiscordApi discordApi;
private final String apiUrl;

/**
* Creates a new wiki parser.
*
* @param api The Discord Api of which to use the HTTP client.
*/
public WikiParser(DiscordApi api) {
this(api, API_URL);
}

/**
* Creates a new Wiki parser.
*
* @param api The Discord Api of which to use the HTTP client.
* @param apiUrl The URL for the json file with the page list.
*/
public WikiParser(DiscordApi api, String apiUrl) {
this.discordApi = api;
this.apiUrl = apiUrl;
}

/**
* Gets the pages asynchronously.
*
* @return The pages of the wiki.
*/
public CompletableFuture<Set<WikiPage>> getPages() {
return CompletableFuture.supplyAsync(() -> {
try {
return getPagesBlocking();
} catch (Throwable t) {
throw new CompletionException(t);
}
}, discordApi.getThreadPool().getExecutorService());
}

/**
* Gets the pages synchronously.
*
* @return The pages of the wiki.
* @throws IOException If the connection to the wiki failed.
*/
public Set<WikiPage> getPagesBlocking() throws IOException {
Request request = new Request.Builder()
.url(apiUrl)
.build();

Response response = client.newCall(request).execute();
ResponseBody body = response.body();
Set<WikiPage> pages = new HashSet<>();
if (body == null) {
return pages;
}
JsonNode array = mapper.readTree(body.charStream());
if (!array.isArray()) {
throw new AssertionError("Format of wiki page list not as expected");
}
for (JsonNode node : array) {
if (node.has("title") && node.has("keywords") && node.has("url") && node.has("content")) {
pages.add(new WikiPage(
node.get("title").asText(),
asStringArray(node.get("keywords")),
node.get("url").asText(),
node.get("content").asText()
));
} else {
throw new AssertionError("Format of wiki page list not as expected");
}
}
return pages;
}

private String[] asStringArray(JsonNode arrayNode) {
if (!arrayNode.isArray()) {
return new String[] {};
}
String[] result = new String[arrayNode.size()];
int i = 0;
for (JsonNode node : arrayNode) {
result[i++] = node.asText();
}
return result;
}

}