diff options
author | Cow <cow@volloeko.de> | 2020-07-05 05:42:45 +0200 |
---|---|---|
committer | Cow <cow@volloeko.de> | 2020-07-05 05:42:45 +0200 |
commit | 1b446698398c648b38311975a6cfd54859ea5cfe (patch) | |
tree | 521ecc4ce9ad968281094eb8c5453dca606931e3 /src/main/java/eu/olli/cowlection/search | |
parent | edaca1fd41a612c71c526ceb20b89c5dec2d81b3 (diff) | |
download | Cowlection-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')
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; + } +} |