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:
@@ -1,6 +1,8 @@
|
|||||||
package com.ts3client.chatlog;
|
package com.ts3client.chatlog;
|
||||||
|
|
||||||
import com.ts3client.text.BBCode;
|
import com.ts3client.text.BBCode;
|
||||||
|
import com.ts3client.text.HtmlText;
|
||||||
|
import com.ts3client.text.TextQuery;
|
||||||
import com.ts3client.text.TsLink;
|
import com.ts3client.text.TsLink;
|
||||||
|
|
||||||
import java.io.BufferedWriter;
|
import java.io.BufferedWriter;
|
||||||
@@ -54,15 +56,40 @@ public final class ChatLogStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<String> serverHtmlHistory() throws IOException {
|
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 {
|
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 {
|
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,
|
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);
|
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();
|
if (!Files.isRegularFile(files.html)) return Collections.emptyList();
|
||||||
List<String> all = Files.readAllLines(files.html, StandardCharsets.UTF_8);
|
|
||||||
List<String> entries = new ArrayList<>();
|
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 (line.startsWith("<p ")) entries.add(line);
|
||||||
}
|
}
|
||||||
if (entries.size() <= limit) return entries;
|
return entries;
|
||||||
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
if (!Files.isRegularFile(files.txt)) return Collections.emptyList();
|
||||||
List<String> all = Files.readAllLines(files.txt, StandardCharsets.UTF_8);
|
|
||||||
List<String> entries = new ArrayList<>();
|
List<String> entries = new ArrayList<>();
|
||||||
for (String line : all) {
|
for (String line : Files.readAllLines(files.txt, StandardCharsets.UTF_8)) {
|
||||||
String entry = textLineToHtml(line, scope);
|
String entry = textLineToHtml(line, scope);
|
||||||
if (entry == null) continue;
|
if (entry != null) entries.add(entry);
|
||||||
entries.add(entry);
|
|
||||||
}
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> last(List<String> entries, int limit) {
|
||||||
if (entries.size() <= limit) return entries;
|
if (entries.size() <= limit) return entries;
|
||||||
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
|
return new ArrayList<>(entries.subList(entries.size() - limit, entries.size()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.ts3client.chatlog;
|
package com.ts3client.chatlog;
|
||||||
|
|
||||||
|
import com.ts3client.text.TextQuery;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
@@ -40,4 +41,31 @@ class ChatLogStoreTest {
|
|||||||
assertTrue(history.get(0).contains("ericek111"));
|
assertTrue(history.get(0).contains("ericek111"));
|
||||||
assertTrue(history.get(0).contains("hello"));
|
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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\"><"x"></span> a & b"));
|
||||||
|
assertEquals("line\nnext", HtmlText.plain("line<br>next"));
|
||||||
|
assertEquals("é", HtmlText.plain("é"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user