From a4057cd49943fa2c31d2001f088335677a15c9e2 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 24 Sep 2026 15:09:45 +0000 Subject: [PATCH] Add text queries and whole-log chat search TextQuery holds a find bar's text and options (match case, whole word, regex) and matches plain text; HtmlText strips a log entry down to it. ChatLogStore can now search a whole log rather than only the history loaded into a chat. Co-Authored-By: Claude Opus 5.5 --- .../com/ts3client/chatlog/ChatLogStore.java | 53 +++++++++--- .../java/com/ts3client/text/HtmlText.java | 54 ++++++++++++ .../java/com/ts3client/text/TextQuery.java | 86 +++++++++++++++++++ .../ts3client/chatlog/ChatLogStoreTest.java | 28 ++++++ .../com/ts3client/text/TextQueryTest.java | 47 ++++++++++ 5 files changed, 255 insertions(+), 13 deletions(-) create mode 100644 ts3-client/core/src/main/java/com/ts3client/text/HtmlText.java create mode 100644 ts3-client/core/src/main/java/com/ts3client/text/TextQuery.java create mode 100644 ts3-client/core/src/test/java/com/ts3client/text/TextQueryTest.java diff --git a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java index 21f9603..f7465fc 100644 --- a/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java +++ b/ts3-client/core/src/main/java/com/ts3client/chatlog/ChatLogStore.java @@ -1,6 +1,8 @@ package com.ts3client.chatlog; import com.ts3client.text.BBCode; +import com.ts3client.text.HtmlText; +import com.ts3client.text.TextQuery; import com.ts3client.text.TsLink; import java.io.BufferedWriter; @@ -54,15 +56,40 @@ public final class ChatLogStore { } public List serverHtmlHistory() throws IOException { - return readLastHtml(log(ChatLogScope.SERVER), HISTORY_LIMIT); + return last(readHtml(log(ChatLogScope.SERVER)), HISTORY_LIMIT); } public List channelHtmlHistory() throws IOException { - return readLastTextMessages(log(ChatLogScope.CHANNEL), HISTORY_LIMIT, ChatLogScope.CHANNEL); + return last(readTextMessages(log(ChatLogScope.CHANNEL), ChatLogScope.CHANNEL), HISTORY_LIMIT); } public List privateHtmlHistory(String clientUniqueId) throws IOException { - return readLastTextMessages(privateLog(clientUniqueId), HISTORY_LIMIT, ChatLogScope.PRIVATE); + return last(readTextMessages(privateLog(clientUniqueId), ChatLogScope.PRIVATE), HISTORY_LIMIT); + } + + /** Entries of a whole log that match a query, in the order they were written. */ + public record SearchResult(List entries, int total) { + public boolean truncated() { + return total > entries.size(); + } + } + + /** + * Searches a whole log, not only the part {@link #HISTORY_LIMIT} loads into a chat. + * + * @param clientUniqueId the peer, for {@link ChatLogScope#PRIVATE}; ignored otherwise + * @param limit how many of the newest matches to return + */ + public SearchResult search(ChatLogScope scope, String clientUniqueId, TextQuery query, int limit) + throws IOException { + List all = scope == ChatLogScope.SERVER + ? readHtml(log(scope)) + : readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope); + List hits = new ArrayList<>(); + for (String entry : all) { + if (query.matches(HtmlText.plain(entry))) hits.add(entry); + } + return new SearchResult(last(hits, limit), hits.size()); } public synchronized String appendMessage(ChatLogScope scope, boolean outgoing, int clientId, String uniqueId, @@ -138,26 +165,26 @@ public final class ChatLogStore { if (!Files.exists(files.txt)) Files.createFile(files.txt); } - private static List readLastHtml(LogFiles files, int limit) throws IOException { + private static List readHtml(LogFiles files) throws IOException { if (!Files.isRegularFile(files.html)) return Collections.emptyList(); - List all = Files.readAllLines(files.html, StandardCharsets.UTF_8); List entries = new ArrayList<>(); - for (String line : all) { + for (String line : Files.readAllLines(files.html, StandardCharsets.UTF_8)) { if (line.startsWith("

(entries.subList(entries.size() - limit, entries.size())); + return entries; } - private static List readLastTextMessages(LogFiles files, int limit, ChatLogScope scope) throws IOException { + private static List readTextMessages(LogFiles files, ChatLogScope scope) throws IOException { if (!Files.isRegularFile(files.txt)) return Collections.emptyList(); - List all = Files.readAllLines(files.txt, StandardCharsets.UTF_8); List entries = new ArrayList<>(); - for (String line : all) { + for (String line : Files.readAllLines(files.txt, StandardCharsets.UTF_8)) { String entry = textLineToHtml(line, scope); - if (entry == null) continue; - entries.add(entry); + if (entry != null) entries.add(entry); } + return entries; + } + + private static List last(List entries, int limit) { if (entries.size() <= limit) return entries; return new ArrayList<>(entries.subList(entries.size() - limit, entries.size())); } diff --git a/ts3-client/core/src/main/java/com/ts3client/text/HtmlText.java b/ts3-client/core/src/main/java/com/ts3client/text/HtmlText.java new file mode 100644 index 0000000..b024b90 --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/text/HtmlText.java @@ -0,0 +1,54 @@ +package com.ts3client.text; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** The readable text of an HTML fragment such as a chat log entry, for searching it. */ +public final class HtmlText { + + private static final Pattern BREAK = Pattern.compile("(?i)|"); + private static final Pattern TAG = Pattern.compile("<[^>]*>"); + private static final Pattern ENTITY = Pattern.compile("&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);"); + + private HtmlText() { + } + + public static String plain(String html) { + if (html == null || html.isEmpty()) return ""; + String text = TAG.matcher(BREAK.matcher(html).replaceAll("\n")).replaceAll(""); + return text.indexOf('&') < 0 ? text : unescape(text); + } + + private static String unescape(String s) { + Matcher m = ENTITY.matcher(s); + StringBuilder out = new StringBuilder(s.length()); + while (m.find()) { + String decoded = decode(m.group(1)); + m.appendReplacement(out, Matcher.quoteReplacement(decoded == null ? m.group() : decoded)); + } + m.appendTail(out); + return out.toString(); + } + + private static String decode(String entity) { + if (entity.charAt(0) == '#') { + try { + int cp = entity.length() > 1 && (entity.charAt(1) == 'x' || entity.charAt(1) == 'X') + ? Integer.parseInt(entity.substring(2), 16) + : Integer.parseInt(entity.substring(1)); + return Character.isValidCodePoint(cp) ? new String(Character.toChars(cp)) : null; + } catch (NumberFormatException e) { + return null; + } + } + return switch (entity) { + case "amp" -> "&"; + case "lt" -> "<"; + case "gt" -> ">"; + case "quot" -> "\""; + case "apos" -> "'"; + case "nbsp" -> " "; + default -> null; + }; + } +} diff --git a/ts3-client/core/src/main/java/com/ts3client/text/TextQuery.java b/ts3-client/core/src/main/java/com/ts3client/text/TextQuery.java new file mode 100644 index 0000000..fad5bcd --- /dev/null +++ b/ts3-client/core/src/main/java/com/ts3client/text/TextQuery.java @@ -0,0 +1,86 @@ +package com.ts3client.text; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * What a find bar is looking for: the typed text and how to match it. Immutable, so a + * query can be handed to a background search while the user keeps typing. + */ +public final class TextQuery { + + public static final TextQuery EMPTY = new TextQuery("", false, false, false); + + public final String text; + public final boolean matchCase; + public final boolean wholeWord; + public final boolean regex; + /** {@code null} when the query is empty or not a valid regular expression. */ + private final Pattern pattern; + private final String error; + + public TextQuery(String text, boolean matchCase, boolean wholeWord, boolean regex) { + this.text = text == null ? "" : text; + this.matchCase = matchCase; + this.wholeWord = wholeWord; + this.regex = regex; + Pattern p = null; + String err = null; + if (!this.text.isEmpty()) { + try { + p = compile(); + } catch (PatternSyntaxException e) { + err = e.getDescription(); + } + } + this.pattern = p; + this.error = err; + } + + private Pattern compile() { + String body = regex ? text : Pattern.quote(text); + if (wholeWord) body = "(? e.contains("needle"))); + ChatLogStore.SearchResult channel = logs.search(ChatLogScope.CHANNEL, null, + new TextQuery("needle", false, false, false), 10); + assertEquals(1, channel.total()); + assertTrue(channel.entries().get(0).contains("the needle is here")); + + ChatLogStore.SearchResult server = logs.search(ChatLogScope.SERVER, null, + new TextQuery("needle", true, false, false), 10); + assertEquals(0, server.total()); + + ChatLogStore.SearchResult capped = logs.search(ChatLogScope.CHANNEL, null, + new TextQuery("filler", false, false, false), 10); + assertEquals(ChatLogStore.HISTORY_LIMIT, capped.total()); + assertEquals(10, capped.entries().size()); + assertTrue(capped.truncated()); + assertTrue(capped.entries().get(9).contains("filler " + (ChatLogStore.HISTORY_LIMIT - 1))); + } } diff --git a/ts3-client/core/src/test/java/com/ts3client/text/TextQueryTest.java b/ts3-client/core/src/test/java/com/ts3client/text/TextQueryTest.java new file mode 100644 index 0000000..6d6a304 --- /dev/null +++ b/ts3-client/core/src/test/java/com/ts3client/text/TextQueryTest.java @@ -0,0 +1,47 @@ +package com.ts3client.text; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TextQueryTest { + + @Test + void ignoresCaseUnlessAsked() { + assertTrue(new TextQuery("hello", false, false, false).matches("Say HELLO there")); + assertFalse(new TextQuery("hello", true, false, false).matches("Say HELLO there")); + assertTrue(new TextQuery("čau", false, false, false).matches("ČAU")); + } + + @Test + void wholeWordsStopAtLettersAndDigitsOnly() { + TextQuery q = new TextQuery("cat", false, true, false); + assertTrue(q.matches("the cat sat")); + assertTrue(q.matches("(cat)")); + assertFalse(q.matches("concatenate")); + assertFalse(q.matches("cat2")); + assertTrue(new TextQuery("@all", false, true, false).matches("hey @all!")); + } + + @Test + void literalUnlessRegex() { + assertFalse(new TextQuery("a.c", false, false, false).matches("abc")); + assertTrue(new TextQuery("a.c", false, false, true).matches("abc")); + } + + @Test + void invalidRegexMatchesNothing() { + TextQuery q = new TextQuery("(", false, false, true); + assertTrue(q.isInvalid()); + assertFalse(q.matches("(")); + } + + @Test + void plainTextOfHtml() { + assertEquals("<\"x\"> a & b", HtmlText.plain("<"x"> a & b")); + assertEquals("line\nnext", HtmlText.plain("line
next")); + assertEquals("é", HtmlText.plain("é")); + } +}