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 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:09:45 +00:00
parent 249430efad
commit a4057cd499
5 changed files with 255 additions and 13 deletions

View File

@@ -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<String> serverHtmlHistory() throws IOException {
return readLastHtml(log(ChatLogScope.SERVER), HISTORY_LIMIT);
return last(readHtml(log(ChatLogScope.SERVER)), HISTORY_LIMIT);
}
public List<String> channelHtmlHistory() throws IOException {
return readLastTextMessages(log(ChatLogScope.CHANNEL), HISTORY_LIMIT, ChatLogScope.CHANNEL);
return last(readTextMessages(log(ChatLogScope.CHANNEL), ChatLogScope.CHANNEL), HISTORY_LIMIT);
}
public List<String> 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<String> 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<String> all = scope == ChatLogScope.SERVER
? readHtml(log(scope))
: readTextMessages(scope == ChatLogScope.PRIVATE ? privateLog(clientUniqueId) : log(scope), scope);
List<String> 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<String> readLastHtml(LogFiles files, int limit) throws IOException {
private static List<String> readHtml(LogFiles files) throws IOException {
if (!Files.isRegularFile(files.html)) return Collections.emptyList();
List<String> all = Files.readAllLines(files.html, StandardCharsets.UTF_8);
List<String> entries = new ArrayList<>();
for (String line : all) {
for (String line : Files.readAllLines(files.html, StandardCharsets.UTF_8)) {
if (line.startsWith("<p ")) entries.add(line);
}
if (entries.size() <= limit) return entries;
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
return entries;
}
private static List<String> readLastTextMessages(LogFiles files, int limit, ChatLogScope scope) throws IOException {
private static List<String> readTextMessages(LogFiles files, ChatLogScope scope) throws IOException {
if (!Files.isRegularFile(files.txt)) return Collections.emptyList();
List<String> all = Files.readAllLines(files.txt, StandardCharsets.UTF_8);
List<String> 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<String> last(List<String> entries, int limit) {
if (entries.size() <= limit) return entries;
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
}

View File

@@ -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)<br\\s*/?>|</(?:p|div|li|tr)>");
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;
};
}
}

View File

@@ -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 = "(?<![\\p{L}\\p{N}_])(?:" + body + ")(?![\\p{L}\\p{N}_])";
int flags = matchCase ? 0 : Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE;
return Pattern.compile(body, flags);
}
/** Nothing typed: every text passes, nothing is highlighted. */
public boolean isEmpty() {
return text.isEmpty();
}
/** The regular expression does not compile; see {@link #error()}. */
public boolean isInvalid() {
return error != null;
}
public String error() {
return error;
}
/** Whether {@code s} contains a match; an empty or invalid query matches nothing. */
public boolean matches(String s) {
return pattern != null && s != null && pattern.matcher(s).find();
}
/** A matcher over {@code s}, or {@code null} for an empty or invalid query. */
public Matcher matcher(CharSequence s) {
return pattern == null ? null : pattern.matcher(s);
}
public TextQuery withText(String newText) {
return new TextQuery(newText, matchCase, wholeWord, regex);
}
@Override
public boolean equals(Object o) {
if (!(o instanceof TextQuery q)) return false;
return text.equals(q.text) && matchCase == q.matchCase && wholeWord == q.wholeWord && regex == q.regex;
}
@Override
public int hashCode() {
return text.hashCode() * 8 + (matchCase ? 4 : 0) + (wholeWord ? 2 : 0) + (regex ? 1 : 0);
}
}

View File

@@ -1,5 +1,6 @@
package com.ts3client.chatlog;
import com.ts3client.text.TextQuery;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -40,4 +41,31 @@ class ChatLogStoreTest {
assertTrue(history.get(0).contains("ericek111"));
assertTrue(history.get(0).contains("hello"));
}
@Test
void searchReachesPastTheLoadedHistory() throws Exception {
ChatLogStore logs = new ChatLogStore(temp, "+Tyg2JtxE8vRNZp+JiUBnmBh0MY=");
logs.appendMessage(ChatLogScope.CHANNEL, false, 5, "uid", "alice", "the needle is here");
for (int i = 0; i < ChatLogStore.HISTORY_LIMIT; i++) {
logs.appendMessage(ChatLogScope.CHANNEL, false, 5, "uid", "alice", "filler " + i);
}
logs.appendServerEvent("a NEEDLE of the server");
assertTrue(logs.channelHtmlHistory().stream().noneMatch(e -> 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)));
}
}

View File

@@ -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("<span class=\"t\">&lt;&quot;x&quot;&gt;</span> a &amp; b"));
assertEquals("line\nnext", HtmlText.plain("line<br>next"));
assertEquals("é", HtmlText.plain("&#233;"));
}
}