aboutsummaryrefslogtreecommitdiff
path: root/src/main/java/eu/olli/cowlection/search
diff options
context:
space:
mode:
authorCow <cow@volloeko.de>2020-07-05 05:42:45 +0200
committerCow <cow@volloeko.de>2020-07-05 05:42:45 +0200
commit1b446698398c648b38311975a6cfd54859ea5cfe (patch)
tree521ecc4ce9ad968281094eb8c5453dca606931e3 /src/main/java/eu/olli/cowlection/search
parentedaca1fd41a612c71c526ceb20b89c5dec2d81b3 (diff)
downloadCowlection-1b446698398c648b38311975a6cfd54859ea5cfe.tar.gz
Cowlection-1b446698398c648b38311975a6cfd54859ea5cfe.tar.bz2
Cowlection-1b446698398c648b38311975a6cfd54859ea5cfe.zip
Renamed mod to Cowlection
Bumped version to 1.8.9-0.7.0
Diffstat (limited to 'src/main/java/eu/olli/cowlection/search')
-rw-r--r--src/main/java/eu/olli/cowlection/search/GuiDateField.java37
-rw-r--r--src/main/java/eu/olli/cowlection/search/GuiSearch.java603
-rw-r--r--src/main/java/eu/olli/cowlection/search/GuiTooltip.java50
-rw-r--r--src/main/java/eu/olli/cowlection/search/LogFilesSearcher.java181
4 files changed, 871 insertions, 0 deletions
diff --git a/src/main/java/eu/olli/cowlection/search/GuiDateField.java b/src/main/java/eu/olli/cowlection/search/GuiDateField.java
new file mode 100644
index 0000000..bb08a02
--- /dev/null
+++ b/src/main/java/eu/olli/cowlection/search/GuiDateField.java
@@ -0,0 +1,37 @@
+package eu.olli.cowlection.search;
+
+import net.minecraft.client.gui.FontRenderer;
+import net.minecraft.client.gui.GuiTextField;
+
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+
+class GuiDateField extends GuiTextField {
+ GuiDateField(int componentId, FontRenderer fontrendererObj, int x, int y, int width, int height) {
+ super(componentId, fontrendererObj, x, y, width, height);
+ }
+
+ LocalDate getDate() {
+ try {
+ return LocalDate.parse(this.getText());
+ } catch (DateTimeParseException e) {
+ return LocalDate.now();
+ }
+ }
+
+ boolean validateDate() {
+ try {
+ LocalDate localDate = LocalDate.parse(this.getText());
+ if (localDate.isAfter(LocalDate.now()) || localDate.isBefore(LocalDate.ofYearDay(2009, 1))) {
+ // searching for things written in the future isn't possible (yet). It is also not possible to perform a search before the existence of mc.
+ setTextColor(0xFFFF3333);
+ return false;
+ }
+ } catch (DateTimeParseException e) {
+ setTextColor(0xFFFF3333);
+ return false;
+ }
+ setTextColor(0xFFFFFF);
+ return true;
+ }
+}
diff --git a/src/main/java/eu/olli/cowlection/search/GuiSearch.java b/src/main/java/eu/olli/cowlection/search/GuiSearch.java
new file mode 100644
index 0000000..d693e59
--- /dev/null
+++ b/src/main/java/eu/olli/cowlection/search/GuiSearch.java
@@ -0,0 +1,603 @@
+package eu.olli.cowlection.search;
+
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.mojang.realmsclient.util.Pair;
+import eu.olli.cowlection.Cowlection;
+import eu.olli.cowlection.config.MooConfig;
+import eu.olli.cowlection.data.LogEntry;
+import eu.olli.cowlection.util.Utils;
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.gui.*;
+import net.minecraft.client.renderer.GlStateManager;
+import net.minecraft.client.renderer.Tessellator;
+import net.minecraft.util.ChatComponentText;
+import net.minecraft.util.EnumChatFormatting;
+import net.minecraft.util.IChatComponent;
+import net.minecraftforge.common.ForgeVersion;
+import net.minecraftforge.fml.client.GuiScrollingList;
+import net.minecraftforge.fml.client.config.GuiButtonExt;
+import net.minecraftforge.fml.client.config.GuiCheckBox;
+import net.minecraftforge.fml.client.config.GuiUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.commons.lang3.tuple.ImmutableTriple;
+import org.lwjgl.input.Keyboard;
+import org.lwjgl.opengl.GL11;
+
+import java.awt.*;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.nio.file.Files;
+import java.nio.file.StandardCopyOption;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.List;
+import java.util.*;
+import java.util.concurrent.*;
+import java.util.stream.Collectors;
+import java.util.zip.GZIPInputStream;
+
+public class GuiSearch extends GuiScreen {
+ private static final String SEARCH_QUERY_PLACE_HOLDER = "Search for...";
+ private final File mcLogOutputFile;
+ /**
+ * @see Executors#newCachedThreadPool()
+ */
+ private final ExecutorService executorService = new ThreadPoolExecutor(0, 1,
+ 60L, TimeUnit.SECONDS,
+ new LinkedBlockingQueue<>(), new ThreadFactoryBuilder().setNameFormat(Cowlection.MODID + "-logfilesearcher-%d").build());
+ // data
+ private String searchQuery;
+ private boolean chatOnly;
+ private boolean matchCase;
+ private boolean removeFormatting;
+ /**
+ * Cached results are required after resizing the client
+ */
+ private List<LogEntry> searchResults;
+ private LocalDate dateStart;
+ private LocalDate dateEnd;
+
+ // gui elements
+ private GuiButton buttonSearch;
+ private GuiButton buttonClose;
+ private GuiButton buttonHelp;
+ private GuiCheckBox checkboxChatOnly;
+ private GuiCheckBox checkboxMatchCase;
+ private GuiCheckBox checkboxRemoveFormatting;
+ private GuiTextField fieldSearchQuery;
+ private GuiDateField fieldDateStart;
+ private GuiDateField fieldDateEnd;
+ private SearchResults guiSearchResults;
+ private List<GuiTooltip> guiTooltips;
+ private boolean isSearchInProgress;
+ private String analyzedFiles;
+ private String analyzedFilesWithHits;
+ private boolean areEntriesSearchResults;
+
+ public GuiSearch(File configDirectory) {
+ this.mcLogOutputFile = new File(configDirectory, "mc-log.txt");
+ try {
+ mcLogOutputFile.createNewFile();
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ this.searchQuery = SEARCH_QUERY_PLACE_HOLDER;
+ this.searchResults = new ArrayList<>();
+ this.dateStart = MooConfig.calculateStartDate();
+ this.dateEnd = LocalDate.now();
+ this.chatOnly = true;
+ }
+
+ /**
+ * Adds the buttons (and other controls) to the screen in question. Called when the GUI is displayed and when the
+ * window resizes, the buttonList is cleared beforehand.
+ */
+ @Override
+ public void initGui() {
+ this.guiTooltips = new ArrayList<>();
+
+ this.fieldSearchQuery = new GuiTextField(42, this.fontRendererObj, this.width / 2 - 100, 13, 200, 20);
+ this.fieldSearchQuery.setMaxStringLength(255);
+ this.fieldSearchQuery.setText(searchQuery);
+ if (SEARCH_QUERY_PLACE_HOLDER.equals(searchQuery)) {
+ this.fieldSearchQuery.setFocused(true);
+ this.fieldSearchQuery.setSelectionPos(0);
+ }
+
+ // date field: start
+ this.fieldDateStart = new GuiDateField(50, this.fontRendererObj, this.width / 2 + 110, 15, 70, 15);
+ this.fieldDateStart.setText(dateStart.toString());
+ addTooltip(fieldDateStart, Arrays.asList(EnumChatFormatting.YELLOW + "Start date", "" + EnumChatFormatting.GRAY + EnumChatFormatting.ITALIC + "Format: " + EnumChatFormatting.RESET + "year-month-day"));
+ // date field: end
+ this.fieldDateEnd = new GuiDateField(51, this.fontRendererObj, this.width / 2 + 110, 35, 70, 15);
+ this.fieldDateEnd.setText(dateEnd.toString());
+ addTooltip(fieldDateEnd, Arrays.asList(EnumChatFormatting.YELLOW + "End date", "" + EnumChatFormatting.GRAY + EnumChatFormatting.ITALIC + "Format: " + EnumChatFormatting.RESET + "year-month-day"));
+
+ // close
+ this.buttonList.add(this.buttonClose = new GuiButtonExt(0, this.width - 25, 3, 22, 20, EnumChatFormatting.RED + "X"));
+ addTooltip(buttonClose, Arrays.asList(EnumChatFormatting.RED + "Close search interface", "" + EnumChatFormatting.GRAY + EnumChatFormatting.ITALIC + "Hint:" + EnumChatFormatting.RESET + " alternatively press ESC"));
+ // help
+ this.buttonList.add(this.buttonHelp = new GuiButtonExt(1, this.width - 25 - 25, 3, 22, 20, "?"));
+ addTooltip(buttonHelp, Collections.singletonList(EnumChatFormatting.YELLOW + "Show help"));
+
+ // chatOnly
+ this.buttonList.add(this.checkboxChatOnly = new GuiCheckBox(21, this.width / 2 - 100, 35, " Chatbox only", chatOnly));
+ addTooltip(checkboxChatOnly, Collections.singletonList(EnumChatFormatting.YELLOW + "Should " + EnumChatFormatting.GOLD + "only " + EnumChatFormatting.YELLOW + "results that have " + EnumChatFormatting.GOLD + "appeared in the chat box " + EnumChatFormatting.YELLOW + "be displayed?\n"
+ + EnumChatFormatting.GRAY + "For example, this " + EnumChatFormatting.WHITE + "excludes error messages" + EnumChatFormatting.GRAY + " but still " + EnumChatFormatting.WHITE + "includes messages sent by a server" + EnumChatFormatting.GRAY + "."));
+ // matchCase
+ this.buttonList.add(this.checkboxMatchCase = new GuiCheckBox(20, this.width / 2 - 100, 45, " Match case", matchCase));
+ addTooltip(checkboxMatchCase, Collections.singletonList(EnumChatFormatting.YELLOW + "Should the search be " + EnumChatFormatting.GOLD + "case-sensitive" + EnumChatFormatting.YELLOW + "?"));
+ // removeFormatting
+ this.buttonList.add(this.checkboxRemoveFormatting = new GuiCheckBox(22, this.width / 2 - 100, 55, " Remove formatting", removeFormatting));
+ addTooltip(checkboxRemoveFormatting, Collections.singletonList(EnumChatFormatting.YELLOW + "Should " + EnumChatFormatting.GOLD + "formatting " + EnumChatFormatting.YELLOW + "and " + EnumChatFormatting.GOLD + "color codes " + EnumChatFormatting.YELLOW + "be " + EnumChatFormatting.GOLD + "removed " + EnumChatFormatting.YELLOW + "from the search results?"));
+ // search
+ this.buttonList.add(this.buttonSearch = new GuiButtonExt(100, this.width / 2 + 40, 40, 60, 20, "Search"));
+
+ this.guiSearchResults = new SearchResults(70);
+ this.guiSearchResults.setResults(searchResults);
+
+ this.setIsSearchInProgress(isSearchInProgress);
+
+ boolean isStartDateValid = fieldDateStart.validateDate();
+ boolean isEndDateValid = fieldDateEnd.validateDate();
+ this.buttonSearch.enabled = !isSearchInProgress && this.fieldSearchQuery.getText().trim().length() > 1 && !this.fieldSearchQuery.getText().startsWith(SEARCH_QUERY_PLACE_HOLDER) && isStartDateValid && isEndDateValid && !dateStart.isAfter(dateEnd);
+
+ if (isStartDateValid && isEndDateValid && dateStart.isAfter(dateEnd)) {
+ fieldDateStart.setTextColor(0xFFDD3333);
+ fieldDateEnd.setTextColor(0xFFCC3333);
+ }
+ }
+
+ private <T extends Gui> void addTooltip(T field, List<String> tooltip) {
+ GuiTooltip guiTooltip = new GuiTooltip(field, tooltip);
+ this.guiTooltips.add(guiTooltip);
+ }
+
+ @Override
+ public void updateScreen() {
+ fieldSearchQuery.updateCursorCounter();
+ fieldDateStart.updateCursorCounter();
+ fieldDateEnd.updateCursorCounter();
+ }
+
+ @Override
+ protected void mouseClicked(int mouseX, int mouseY, int mouseButton) throws IOException {
+ // allow clicks on 'close' button even while a search is in progress
+ super.mouseClicked(mouseX, mouseY, mouseButton);
+ if (isSearchInProgress) {
+ // search in progress, abort
+ return;
+ }
+ fieldSearchQuery.mouseClicked(mouseX, mouseY, mouseButton);
+ fieldDateStart.mouseClicked(mouseX, mouseY, mouseButton);
+ fieldDateEnd.mouseClicked(mouseX, mouseY, mouseButton);
+ }
+
+ @Override
+ protected void keyTyped(char typedChar, int keyCode) throws IOException {
+ if (isSearchInProgress && keyCode != Keyboard.KEY_ESCAPE) {
+ // search in progress, don't process key typed - but allow escape to exit gui
+ return;
+ }
+ if (dateStart.isBefore(dateEnd)) {
+ fieldDateStart.setTextColor(0xFFFFFFFF);
+ fieldDateEnd.setTextColor(0xFFFFFFFF);
+ }
+ if (keyCode == Keyboard.KEY_RETURN && this.fieldSearchQuery.isFocused()) {
+ // perform search
+ actionPerformed(buttonSearch);
+ } else if (this.fieldSearchQuery.textboxKeyTyped(typedChar, keyCode)) {
+ searchQuery = this.fieldSearchQuery.getText();
+ } else if (this.fieldDateStart.textboxKeyTyped(typedChar, keyCode)) {
+ if (fieldDateStart.validateDate()) {
+ dateStart = fieldDateStart.getDate();
+ }
+ } else if (this.fieldDateEnd.textboxKeyTyped(typedChar, keyCode)) {
+ if (fieldDateEnd.validateDate()) {
+ dateEnd = fieldDateEnd.getDate();
+ }
+ } else if (GuiScreen.isKeyComboCtrlA(keyCode)) {
+ // copy all search results
+ String searchResults = guiSearchResults.getAllSearchResults();
+ if (!searchResults.isEmpty()) {
+ GuiScreen.setClipboardString(searchResults);
+ }
+ } else if (GuiScreen.isKeyComboCtrlC(keyCode)) {
+ // copy current selected entry
+ LogEntry selectedSearchResult = guiSearchResults.getSelectedSearchResult();
+ if (selectedSearchResult != null) {
+ GuiScreen.setClipboardString(EnumChatFormatting.getTextWithoutFormattingCodes(selectedSearchResult.getMessage()));
+ }
+ } else if (keyCode == Keyboard.KEY_C && isCtrlKeyDown() && isShiftKeyDown() && !isAltKeyDown()) {
+ // copy current selected entry with formatting codes
+ LogEntry selectedSearchResult = guiSearchResults.getSelectedSearchResult();
+ if (selectedSearchResult != null) {
+ GuiScreen.setClipboardString(selectedSearchResult.getMessage());
+ }
+ } else {
+ if (keyCode == Keyboard.KEY_ESCAPE) {
+ guiSearchResults = null;
+ }
+ super.keyTyped(typedChar, keyCode);
+ }
+
+ boolean isStartDateValid = fieldDateStart.validateDate();
+ boolean isEndDateValid = fieldDateEnd.validateDate();
+ this.buttonSearch.enabled = !isSearchInProgress && searchQuery.trim().length() > 1 && !searchQuery.startsWith(SEARCH_QUERY_PLACE_HOLDER) && isStartDateValid && isEndDateValid && !dateStart.isAfter(dateEnd);
+
+ if (isStartDateValid && isEndDateValid && dateStart.isAfter(dateEnd)) {
+ fieldDateStart.setTextColor(0xFFDD3333);
+ fieldDateEnd.setTextColor(0xFFCC3333);
+ }
+ }
+
+ @Override
+ public void drawScreen(int mouseX, int mouseY, float partialTicks) {
+ this.drawDefaultBackground();
+ this.drawCenteredString(this.fontRendererObj, EnumChatFormatting.BOLD + "Minecraft Log Search", this.width / 2, 2, 0xFFFFFF);
+ this.fieldSearchQuery.drawTextBox();
+ this.fieldDateStart.drawTextBox();
+ this.fieldDateEnd.drawTextBox();
+ this.guiSearchResults.drawScreen(mouseX, mouseY, partialTicks);
+
+ super.drawScreen(mouseX, mouseY, partialTicks);
+
+ for (GuiTooltip guiTooltip : guiTooltips) {
+ if (guiTooltip.checkHover(mouseX, mouseY)) {
+ drawHoveringText(guiTooltip.getText(), mouseX, mouseY, 300);
+ // only one tooltip can be displayed at a time: break!
+ break;
+ }
+ }
+ }
+
+ @Override
+ protected void actionPerformed(GuiButton button) throws IOException {
+ if (button == this.buttonClose && button.enabled) {
+ guiSearchResults = null;
+ this.mc.setIngameFocus();
+ }
+ if (isSearchInProgress || !button.enabled) {
+ return;
+ }
+ if (button == this.buttonSearch) {
+ setIsSearchInProgress(true);
+
+ executorService.execute(() -> {
+ try {
+ ImmutableTriple<Integer, Integer, List<LogEntry>> searchResultsData = new LogFilesSearcher().searchFor(this.fieldSearchQuery.getText(), checkboxChatOnly.isChecked(), checkboxMatchCase.isChecked(), checkboxRemoveFormatting.isChecked(), dateStart, dateEnd);
+ this.searchResults = searchResultsData.right;
+ this.analyzedFiles = "Analyzed files: " + EnumChatFormatting.WHITE + searchResultsData.left;
+ this.analyzedFilesWithHits = "Files with hits: " + EnumChatFormatting.WHITE + searchResultsData.middle;
+ if (this.searchResults.isEmpty()) {
+ this.searchResults.add(new LogEntry(EnumChatFormatting.ITALIC + "No results"));
+ areEntriesSearchResults = false;
+ } else {
+ areEntriesSearchResults = true;
+ }
+ } catch (IOException e) {
+ System.err.println("Error reading/parsing file log files:");
+ e.printStackTrace();
+ if (e.getStackTrace().length > 0) {
+ searchResults.add(new LogEntry(StringUtils.replaceEach(ExceptionUtils.getStackTrace(e), new String[]{"\t", "\r\n"}, new String[]{" ", "\n"})));
+ }
+ }
+ Minecraft.getMinecraft().addScheduledTask(() -> {
+ this.guiSearchResults.setResults(this.searchResults);
+ setIsSearchInProgress(false);
+ });
+ });
+ } else if (button == checkboxChatOnly) {
+ chatOnly = checkboxChatOnly.isChecked();
+ } else if (button == checkboxMatchCase) {
+ matchCase = checkboxMatchCase.isChecked();
+ } else if (button == checkboxRemoveFormatting) {
+ removeFormatting = checkboxRemoveFormatting.isChecked();
+ } else if (button == buttonHelp) {
+ this.areEntriesSearchResults = false;
+ this.searchResults.clear();
+ this.searchResults.add(new LogEntry("" + EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + "Initial setup/Configuration " + EnumChatFormatting.GRAY + EnumChatFormatting.ITALIC + "/moo config"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 1) " + EnumChatFormatting.RESET + "Configure directories that should be scanned for log files (\"Directories with Minecraft log files\")"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 2) " + EnumChatFormatting.RESET + "Set default starting date (\"Start date for log file search\")"));
+ this.searchResults.add(new LogEntry("" + EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + "Performing a search " + EnumChatFormatting.GRAY + EnumChatFormatting.ITALIC + "/moo search"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 1) " + EnumChatFormatting.RESET + "Enter search term"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 2) " + EnumChatFormatting.RESET + "Adjust start and end date"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 3) " + EnumChatFormatting.RESET + "Select desired options (match case, ...)"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " 4) " + EnumChatFormatting.RESET + "Click 'Search'"));
+ this.searchResults.add(new LogEntry("" + EnumChatFormatting.GOLD + EnumChatFormatting.BOLD + "Search results"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " - " + EnumChatFormatting.YELLOW + "CTRL + C " + EnumChatFormatting.RESET + "to copy selected search result"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " - " + EnumChatFormatting.YELLOW + "CTRL + Shift + C " + EnumChatFormatting.RESET + "to copy selected search result " + EnumChatFormatting.ITALIC + "with" + EnumChatFormatting.RESET + " formatting codes"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " - " + EnumChatFormatting.YELLOW + "CTRL + A " + EnumChatFormatting.RESET + "to copy all search results"));
+ this.searchResults.add(new LogEntry(EnumChatFormatting.GOLD + " - " + EnumChatFormatting.YELLOW + "Double click search result " + EnumChatFormatting.RESET + "to open corresponding log file in default text editor"));
+ this.guiSearchResults.setResults(searchResults);
+ }
+ }
+
+ private void setIsSearchInProgress(boolean isSearchInProgress) {
+ this.isSearchInProgress = isSearchInProgress;
+ buttonSearch.enabled = !isSearchInProgress;
+ fieldSearchQuery.setEnabled(!isSearchInProgress);
+ fieldDateStart.setEnabled(!isSearchInProgress);
+ fieldDateEnd.setEnabled(!isSearchInProgress);
+ checkboxChatOnly.enabled = !isSearchInProgress;
+ checkboxMatchCase.enabled = !isSearchInProgress;
+ checkboxRemoveFormatting.enabled = !isSearchInProgress;
+ if (isSearchInProgress) {
+ fieldSearchQuery.setFocused(false);
+ fieldDateStart.setFocused(false);
+ fieldDateEnd.setFocused(false);
+ buttonSearch.displayString = EnumChatFormatting.ITALIC + "Searching";
+ searchResults.clear();
+ guiSearchResults.clearResults();
+ analyzedFiles = null;
+ analyzedFilesWithHits = null;
+ } else {
+ buttonSearch.displayString = "Search";
+ }
+ }
+
+ private void drawHoveringText(List<String> textLines, int mouseX, int mouseY, int maxTextWidth) {
+ if (ForgeVersion.getBuildVersion() < 1808) {
+ // we're running a forge version from before 24 March 2016 (http://files.minecraftforge.net/maven/net/minecraftforge/forge/index_1.8.9.html for reference)
+ // using mc built-in method
+ drawHoveringText(textLines, mouseX, mouseY, fontRendererObj);
+ } else {
+ // we're on a newer forge version, so we can use the improved tooltip rendering added in 1.8.9-11.15.1.1808 (released 03/24/16 09:25 PM) in this pull request: https://github.com/MinecraftForge/MinecraftForge/pull/2649
+ GuiUtils.drawHoveringText(textLines, mouseX, mouseY, width, height, maxTextWidth, fontRendererObj);
+ }
+ }
+
+ /**
+ * List gui element similar to GuiModList.Info
+ */
+ class SearchResults extends GuiScrollingList {
+ private final String[] spinner = new String[]{"oooooo", "Oooooo", "oOoooo", "ooOooo", "oooOoo", "ooooOo", "oooooO"};
+ private final DateTimeFormatter coloredDateFormatter = DateTimeFormatter.ofPattern(EnumChatFormatting.GRAY + "HH" + EnumChatFormatting.DARK_GRAY + ":" + EnumChatFormatting.GRAY + "mm" + EnumChatFormatting.DARK_GRAY + ":" + EnumChatFormatting.GRAY + "ss");
+ private List<LogEntry> rawResults;
+ private List<IChatComponent> slotsData;
+ /**
+ * key: slot id of 1st line of a search result (if multi-line-result), value: search result id
+ */
+ private NavigableMap<Integer, Integer> searchResultEntries;
+ private Pair<Long, String> errorMessage;
+ private String resultsCount;
+
+ SearchResults(int marginTop) {
+ super(GuiSearch.this.mc,
+ GuiSearch.this.width - 10, // 5 pixel margin each
+ GuiSearch.this.height - marginTop - 5,
+ marginTop, GuiSearch.this.height - 5,
+ 5, 12,
+ GuiSearch.this.width,
+ GuiSearch.this.height);
+ this.rawResults = Collections.emptyList();
+ this.slotsData = Collections.emptyList();
+ this.searchResultEntries = Collections.emptyNavigableMap();
+ }
+
+ @Override
+ public void drawScreen(int mouseX, int mouseY, float partialTicks) {
+ super.drawScreen(mouseX, mouseY, partialTicks);
+ if (isSearchInProgress) {
+ // spinner taken from IProgressMeter and GuiAchievements#drawScreen
+ GuiSearch.this.drawCenteredString(GuiSearch.this.fontRendererObj, "Searching for '" + GuiSearch.this.searchQuery + "'", GuiSearch.this.width / 2, GuiSearch.this.height / 2, 16777215);
+ GuiSearch.this.drawCenteredString(GuiSearch.this.fontRendererObj, spinner[(int) (Minecraft.getSystemTime() / 150L % (long) spinner.length)], GuiSearch.this.width / 2, GuiSearch.this.height / 2 + GuiSearch.this.fontRendererObj.FONT_HEIGHT * 2, 16777215);
+ }
+ int hoveredSlotId = this.func_27256_c(mouseX, mouseY);
+ if (hoveredSlotId >= 0 && mouseY > top && mouseY < bottom) {
+ float scrollDistance = getScrollDistance();
+ if (scrollDistance != Float.MIN_VALUE) {
+ // draw hovered entry details
+
+ int hoveredSearchResultId = getSearchResultIdBySlotId(hoveredSlotId);
+ LogEntry hoveredEntry = getSearchResultByResultId(hoveredSearchResultId);
+ if (hoveredEntry != null && !hoveredEntry.isError()) {
+ // draw 'tooltips' in the top left corner
+ drawString(fontRendererObj, "Log file: ", 2, 2, 0xff888888);
+ GlStateManager.pushMatrix();
+ float scaleFactor = 0.75f;
+ GL11.glScalef(scaleFactor, scaleFactor, scaleFactor);
+ fontRendererObj.drawSplitString(EnumChatFormatting.GRAY + Utils.toRealPath(hoveredEntry.getFilePath()), 5, (int) ((4 + fontRendererObj.FONT_HEIGHT) * (1 / scaleFactor)), (int) ((GuiSearch.this.fieldSearchQuery.xPosition - 8) * (1 / scaleFactor)), 0xff888888);
+ GlStateManager.popMatrix();
+ drawString(fontRendererObj, "Result: " + EnumChatFormatting.WHITE + (hoveredSearchResultId + 1) + EnumChatFormatting.RESET + "/" + EnumChatFormatting.WHITE + this.rawResults.size(), 8, 48, 0xff888888);
+ drawString(fontRendererObj, "Time: " + hoveredEntry.getTime().format(coloredDateFormatter), 8, 58, 0xff888888);
+ }
+
+ // formula from GuiScrollingList#drawScreen slotTop
+ int baseY = this.top + /* border: */4 - (int) scrollDistance;
+
+ // highlight multiline search results
+ Integer resultIndexStart = searchResultEntries.floorKey(hoveredSlotId);
+ Integer resultIndexEnd = searchResultEntries.higherKey(hoveredSlotId);
+
+ if (resultIndexStart == null) {
+ return;
+ } else if (resultIndexEnd == null) {
+ // last result entry
+ resultIndexEnd = getSize();
+ }
+
+ int slotTop = baseY + resultIndexStart * this.slotHeight - 2;
+ int slotBottom = baseY + resultIndexEnd * this.slotHeight - 2;
+ drawRect(this.left, Math.max(slotTop, top), right - /* scrollBar: */7, Math.min(slotBottom, bottom), 0x22ffffff);
+ }
+ } else if (areEntriesSearchResults) {
+ if (analyzedFiles != null) {
+ drawString(fontRendererObj, analyzedFiles, 8, 22, 0xff888888);
+ }
+ if (analyzedFilesWithHits != null) {
+ drawString(fontRendererObj, analyzedFilesWithHits, 8, 32, 0xff888888);
+ }
+ if (resultsCount != null) {
+ drawString(fontRendererObj, resultsCount, 8, 48, 0xff888888);
+ }
+ }
+ if (errorMessage != null) {
+ if (errorMessage.first().compareTo(System.currentTimeMillis()) > 0) {
+ String errorText = "Error: " + EnumChatFormatting.RED + errorMessage.second();
+ int stringWidth = fontRendererObj.getStringWidth(errorText);
+ int margin = 5;
+ int left = width / 2 - stringWidth / 2 - margin;
+ int top = height / 2 - margin;
+ drawRect(left, top, left + stringWidth + 2 * margin, top + fontRendererObj.FONT_HEIGHT + 2 * margin, 0xff000000);
+ drawCenteredString(fontRendererObj, errorText,/* 2, 30*/width / 2, height / 2, 0xffDD1111);
+ } else {
+ errorMessage = null;
+ }
+ }
+ }
+
+ private float getScrollDistance() {
+ Field scrollDistanceField = FieldUtils.getField(GuiScrollingList.class, "scrollDistance", true);
+ if (scrollDistanceField == null) {
+ // scrollDistance field not found in class GuiScrollingList
+ return Float.MIN_VALUE;
+ }
+ try {
+ return (float) scrollDistanceField.get(this);
+ } catch (IllegalAccessException e) {
+ e.printStackTrace();
+ return Float.MIN_VALUE;
+ }
+ }
+
+ @Override
+ protected int getSize() {
+ return slotsData.size();
+ }
+
+ @Override
+ protected void elementClicked(int index, boolean doubleClick) {
+ if (doubleClick) {
+ int searchResultIdBySlotId = getSearchResultIdBySlotId(index);
+ LogEntry searchResult = rawResults.get(searchResultIdBySlotId);
+ if (searchResult.getFilePath() == null) {
+ setErrorMessage("This log entry is not from a file");
+ return;
+ }
+ byte[] buffer = new byte[1024];
+ String logFileName = Utils.toRealPath(searchResult.getFilePath());
+ if (logFileName.endsWith("latest.log")) {
+ try {
+ Files.copy(searchResult.getFilePath(), mcLogOutputFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else { // .log.gz
+ String newLine = System.getProperty("line.separator");
+ String fileHeader = "# Original filename: " + logFileName + newLine + "# Use CTRL + F to search for specific words" + newLine + newLine;
+ try (GZIPInputStream logFileGzipped = new GZIPInputStream(new FileInputStream(logFileName));
+ FileOutputStream logFileUnGzipped = new FileOutputStream(mcLogOutputFile)) {
+ logFileUnGzipped.write(fileHeader.getBytes());
+ int len;
+ while ((len = logFileGzipped.read(buffer)) > 0) {
+ logFileUnGzipped.write(buffer, 0, len);
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ try {
+ Desktop.getDesktop().open(mcLogOutputFile);
+ } catch (IOException e) {
+ setErrorMessage("File extension .txt has no associated default editor");
+ e.printStackTrace();
+ } catch (IllegalArgumentException e) {
+ setErrorMessage(e.getMessage()); // The file: <path> doesn't exist.
+ e.printStackTrace();
+ } catch (UnsupportedOperationException e) {
+ setErrorMessage("Can't open files on this OS");
+ e.printStackTrace();
+ }
+ }
+ }
+
+ private void setErrorMessage(String errorMessage) {
+ int showDuration = 10000; // ms
+ this.errorMessage = Pair.of(System.currentTimeMillis() + showDuration, errorMessage);
+ }
+
+ @Override
+ protected boolean isSelected(int index) {
+ return false;
+ }
+
+ @Override
+ protected void drawBackground() {
+ }
+
+ @Override
+ protected void drawSlot(int slotIdx, int entryRight, int slotTop, int slotBuffer, Tessellator tess) {
+ int drawnResultIndex = searchResultEntries.floorKey(slotIdx);
+ if (Objects.equals(searchResultEntries.floorKey(selectedIndex), drawnResultIndex)) {
+ // highlight all lines of selected entry
+ drawRect(this.left, slotTop - 2, entryRight, slotTop + slotHeight - 2, 0x99000000);
+ }
+ IChatComponent slotData = slotsData.get(slotIdx);
+ if (slotData != null) {
+ GlStateManager.enableBlend();
+ GuiSearch.this.fontRendererObj.drawStringWithShadow(slotData.getFormattedText(), this.left + 4, slotTop, 0xFFFFFF);
+ GlStateManager.disableAlpha();
+ GlStateManager.disableBlend();
+ }
+ }
+
+ private void setResults(List<LogEntry> searchResult) {
+ this.rawResults = searchResult;
+ this.slotsData = resizeContent(searchResult);
+ if (GuiSearch.this.areEntriesSearchResults) {
+ this.resultsCount = "Results: " + EnumChatFormatting.WHITE + this.rawResults.size();
+ }
+ }
+
+ private void clearResults() {
+ this.rawResults = Collections.emptyList();
+ this.resultsCount = null;
+ this.slotsData = resizeContent(Collections.emptyList());
+ }
+
+ private List<IChatComponent> resizeContent(List<LogEntry> searchResults) {
+ this.searchResultEntries = new TreeMap<>();
+ List<IChatComponent> slotsData = new ArrayList<>();
+ for (int searchResultIndex = 0; searchResultIndex < searchResults.size(); searchResultIndex++) {
+ LogEntry searchResult = searchResults.get(searchResultIndex);
+
+ String searchResultEntry;
+ if (searchResult.isError()) {
+ searchResultEntry = searchResult.getMessage();
+ } else {
+ searchResultEntry = EnumChatFormatting.DARK_GRAY + searchResult.getTime().format(DateTimeFormatter.ISO_LOCAL_DATE) + " " + EnumChatFormatting.RESET + searchResult.getMessage();
+ }
+ searchResultEntries.put(slotsData.size(), searchResultIndex);
+ List<IChatComponent> multilineResult = GuiUtilRenderComponents.splitText(new ChatComponentText(searchResultEntry), this.listWidth - 8, GuiSearch.this.fontRendererObj, false, true);
+ slotsData.addAll(multilineResult);
+ }
+ return slotsData;
+ }
+
+ LogEntry getSelectedSearchResult() {
+ int searchResultId = getSearchResultIdBySlotId(selectedIndex);
+ return getSearchResultByResultId(searchResultId);
+ }
+
+ private LogEntry getSearchResultByResultId(int searchResultId) {
+ return (searchResultId >= 0 && searchResultId < rawResults.size()) ? rawResults.get(searchResultId) : null;
+ }
+
+ private int getSearchResultIdBySlotId(int slotId) {
+ Map.Entry<Integer, Integer> searchResultIds = searchResultEntries.floorEntry(slotId);
+ return searchResultIds != null ? searchResultIds.getValue() : -1;
+ }
+
+ String getAllSearchResults() {
+ return rawResults.stream().map(logEntry -> EnumChatFormatting.getTextWithoutFormattingCodes(logEntry.getMessage()))
+ .collect(Collectors.joining("\n"));
+ }
+ }
+}
diff --git a/src/main/java/eu/olli/cowlection/search/GuiTooltip.java b/src/main/java/eu/olli/cowlection/search/GuiTooltip.java
new file mode 100644
index 0000000..f76bf2d
--- /dev/null
+++ b/src/main/java/eu/olli/cowlection/search/GuiTooltip.java
@@ -0,0 +1,50 @@
+package eu.olli.cowlection.search;
+
+import net.minecraft.client.gui.Gui;
+import net.minecraft.client.gui.GuiButton;
+import net.minecraft.client.gui.GuiTextField;
+import net.minecraftforge.fml.client.config.GuiCheckBox;
+import net.minecraftforge.fml.client.config.HoverChecker;
+
+import java.util.List;
+
+public class GuiTooltip {
+ private final HoverChecker hoverChecker;
+ private final List<String> tooltip;
+
+ public <T extends Gui> GuiTooltip(T field, List<String> tooltip) {
+ if (field instanceof GuiCheckBox) {
+ // checkbox
+ GuiCheckBox guiCheckBox = (GuiCheckBox) field;
+ int top = guiCheckBox.yPosition;
+ int bottom = guiCheckBox.yPosition + guiCheckBox.height;
+ int left = guiCheckBox.xPosition;
+ int right = guiCheckBox.xPosition + guiCheckBox.width;
+
+ this.hoverChecker = new HoverChecker(top, bottom, left, right, 300);
+ } else if (field instanceof GuiTextField) {
+ // text field
+ GuiTextField guiTextField = (GuiTextField) field;
+ int top = guiTextField.yPosition;
+ int bottom = guiTextField.yPosition + guiTextField.height;
+ int left = guiTextField.xPosition;
+ int right = guiTextField.xPosition + guiTextField.width;
+
+ this.hoverChecker = new HoverChecker(top, bottom, left, right, 300);
+ } else if (field instanceof GuiButton) {
+ // button
+ this.hoverChecker = new HoverChecker((GuiButton) field, 300);
+ } else {
+ throw new IllegalArgumentException("Tried to add a tooltip to an illegal field type: " + field.getClass());
+ }
+ this.tooltip = tooltip;
+ }
+
+ public List<String> getText() {
+ return tooltip;
+ }
+
+ public boolean checkHover(int mouseX, int mouseY) {
+ return hoverChecker.checkHover(mouseX, mouseY);
+ }
+}
diff --git a/src/main/java/eu/olli/cowlection/search/LogFilesSearcher.java b/src/main/java/eu/olli/cowlection/search/LogFilesSearcher.java
new file mode 100644
index 0000000..7aeb2aa
--- /dev/null
+++ b/src/main/java/eu/olli/cowlection/search/LogFilesSearcher.java
@@ -0,0 +1,181 @@
+package eu.olli.cowlection.search;
+
+import eu.olli.cowlection.config.MooConfig;
+import eu.olli.cowlection.data.LogEntry;
+import net.minecraft.util.EnumChatFormatting;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.tuple.ImmutableTriple;
+
+import java.io.*;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.zip.GZIPInputStream;
+
+class LogFilesSearcher {
+ /**
+ * Log4j.xml PatternLayout: [%d{HH:mm:ss}] [%t/%level]: %msg%n
+ * Log line: [TIME] [THREAD/LEVEL]: [CHAT] msg
+ * examples:
+ * - [13:33:37] [Client thread/INFO]: [CHAT] Hello World
+ * - [08:15:42] [Client thread/ERROR]: Item entity 9001 has no item?!
+ */
+ private static final Pattern LOG4J_PATTERN = Pattern.compile("^\\[(?<timeHours>[\\d]{2}):(?<timeMinutes>[\\d]{2}):(?<timeSeconds>[\\d]{2})] \\[(?<thread>[^/]+)/(?<logLevel>[A-Z]+)]:(?<isChat> \\[CHAT])? (?<message>.*)$");
+ private int analyzedFilesWithHits = 0;
+
+ ImmutableTriple<Integer, Integer, List<LogEntry>> searchFor(String searchQuery, boolean chatOnly, boolean matchCase, boolean removeFormatting, LocalDate dateStart, LocalDate dateEnd) throws IOException {
+ List<Path> files = new ArrayList<>();
+ for (String logsDirPath : MooConfig.logsDirs) {
+ File logsDir = new File(logsDirPath);
+ if (logsDir.exists() && logsDir.isDirectory()) {
+ try {
+ files.addAll(fileList(logsDir, dateStart, dateEnd));
+ } catch (IOException e) {
+ throw throwIoException(logsDirPath, e);
+ }
+ }
+ }
+
+ if (files.isEmpty()) {
+ throw new FileNotFoundException(EnumChatFormatting.DARK_RED + "ERROR: Couldn't find any Minecraft log files. Please check if the log file directories are set correctly (/moo config).");
+ } else {
+ List<LogEntry> searchResults = analyzeFiles(files, searchQuery, chatOnly, matchCase, removeFormatting)
+ .stream().sorted(Comparator.comparing(LogEntry::getTime)).collect(Collectors.toList());
+ return new ImmutableTriple<>(files.size(), analyzedFilesWithHits, searchResults);
+ }
+ }
+
+ private List<LogEntry> analyzeFiles(List<Path> paths, String searchTerm, boolean chatOnly, boolean matchCase, boolean removeFormatting) throws IOException {
+ List<LogEntry> searchResults = new ArrayList<>();
+ for (Path path : paths) {
+ boolean foundSearchTermInFile = false;
+ try (BufferedReader in = (path.endsWith("latest.log")
+ ? new BufferedReader(new InputStreamReader(new FileInputStream(path.toFile()))) // latest.log
+ : new BufferedReader(new InputStreamReader(new GZIPInputStream(new FileInputStream(path.toFile())))))) { // ....log.gz
+ String fileName = path.getFileName().toString(); // 2020-04-20-3.log.gz
+ String date = fileName.equals("latest.log")
+ ? LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)
+ : fileName.substring(0, fileName.lastIndexOf('-'));
+ String content;
+ LogEntry logEntry = null;
+ while ((content = in.readLine()) != null) {
+ Matcher logLineMatcher = LOG4J_PATTERN.matcher(content);
+ if (logLineMatcher.matches()) { // current line is a new log entry
+ if (logEntry != null) {
+ // we had a previous log entry; analyze it!
+ LogEntry result = analyzeLogEntry(logEntry, searchTerm, matchCase, removeFormatting);
+ if (result != null) {
+ searchResults.add(result);
+ foundSearchTermInFile = true;
+ }
+ logEntry = null;
+ }
+ // handle first line of new log entry
+ if (chatOnly && logLineMatcher.group("isChat") == null) {
+ // not a chat log entry, although we're only searching for chat messages, abort!
+ continue;
+ }
+ LocalDateTime dateTime = getDate(date, logLineMatcher);
+ logEntry = new LogEntry(dateTime, path, logLineMatcher.group("message"));
+ } else if (logEntry != null) {
+ // multiline log entry
+ logEntry.addLogLine(content);
+ }
+ }
+ if (logEntry != null) {
+ // end of file! analyze last log entry in file
+ LogEntry result = analyzeLogEntry(logEntry, searchTerm, matchCase, removeFormatting);
+ if (result != null) {
+ searchResults.add(result);
+ foundSearchTermInFile = true;
+ }
+ }
+ if (foundSearchTermInFile) {
+ analyzedFilesWithHits++;
+ }
+ } catch (IOException e) {
+ throw throwIoException(path.toString(), e);
+ }
+ }
+ return searchResults;
+ }
+
+ private LocalDateTime getDate(String date, Matcher logLineMatcher) {
+ int year = Integer.parseInt(date.substring(0, 4));
+ int month = Integer.parseInt(date.substring(5, 7));
+ int day = Integer.parseInt(date.substring(8, 10));
+ int hour = Integer.parseInt(logLineMatcher.group(1));
+ int minute = Integer.parseInt(logLineMatcher.group(2));
+ int sec = Integer.parseInt(logLineMatcher.group(3));
+
+ return LocalDateTime.of(year, month, day, hour, minute, sec);
+ }
+
+ private LogEntry analyzeLogEntry(LogEntry logEntry, String searchTerms, boolean matchCase, boolean removeFormatting) {
+ if (logEntry.getMessage().length() > 5000) {
+ // avoid ultra long log entries
+ return null;
+ }
+ logEntry.fixWeirdCharacters();
+
+ if (removeFormatting) {
+ logEntry.removeFormatting();
+ }
+ String logMessage = logEntry.getMessage();
+ if (!matchCase) {
+ if (!StringUtils.containsIgnoreCase(logMessage, searchTerms)) {
+ // no result, abort
+ return null;
+ }
+ } else if (!logMessage.contains(searchTerms)) {
+ // no result, abort
+ return null;
+ }
+
+ return logEntry;
+ }
+
+ private List<Path> fileList(File directory, LocalDate startDate, LocalDate endDate) throws IOException {
+ List<Path> fileNames = new ArrayList<>();
+ try (DirectoryStream<Path> directoryStream = Files.newDirectoryStream(directory.toPath())) {
+ for (Path path : directoryStream) {
+ if (path.toString().endsWith(".log.gz")) {
+ String[] fileDate = path.getFileName().toString().split("-");
+ if (fileDate.length == 4) {
+ LocalDate fileLocalDate = LocalDate.of(Integer.parseInt(fileDate[0]),
+ Integer.parseInt(fileDate[1]), Integer.parseInt(fileDate[2]));
+
+ if (fileLocalDate.compareTo(startDate) >= 0 && fileLocalDate.compareTo(endDate) <= 0) {
+ fileNames.add(path);
+ }
+ } else {
+ System.err.println("Error with " + path.toString());
+ }
+ } else if (path.getFileName().toString().equals("latest.log")) {
+ LocalDate lastModified = Instant.ofEpochMilli(path.toFile().lastModified()).atZone(ZoneId.systemDefault()).toLocalDate();
+ if (!lastModified.isBefore(startDate) && !lastModified.isAfter(endDate)) {
+ fileNames.add(path);
+ }
+ }
+ }
+ }
+ return fileNames;
+ }
+
+ private IOException throwIoException(String file, IOException e) throws IOException {
+ IOException ioException = new IOException(EnumChatFormatting.DARK_RED + "ERROR: An error occurred trying to read/parse '" + EnumChatFormatting.RED + file + EnumChatFormatting.DARK_RED + "'");
+ ioException.setStackTrace(e.getStackTrace());
+ throw ioException;
+ }
+}