diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e24d4952e0..1992ac41dba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Fixed +- We improved the group filter to support full boolean search syntax. [#12721](https://github.com/JabRef/jabref/issues/12721) - We fixed the column chooser context menu appearing when right-clicking the empty library table body. [#15384](https://github.com/JabRef/jabref/issues/15384) - We fixed web search rejecting queries with non-standard syntax. [#12637](https://github.com/JabRef/jabref/issues/12637) - We fixed an issue where multiline property of fields could not be removed properly. [#11897](https://github.com/JabRef/jabref/issues/11897) diff --git a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java index abed0f8eb85..5a09c83ebd8 100644 --- a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java @@ -31,6 +31,7 @@ import org.jabref.logic.bibtex.FieldPreferences; import org.jabref.logic.groups.GroupsFactory; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.search.query.GroupNameFilterVisitor; import org.jabref.logic.util.TaskExecutor; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -109,7 +110,9 @@ public GroupTreeViewModel(@NonNull StateManager stateManager, EasyBind.subscribe(selectedGroups, this::onSelectedGroupChanged); // Set-up bindings - filterPredicate.bind(EasyBind.map(filterText, text -> group -> group.isMatchedBy(text))); + filterPredicate.bind(EasyBind.map(filterText, text -> + group -> GroupNameFilterVisitor.matches(group.getDisplayName(), text) + )); } private void refresh() { diff --git a/jabgui/src/test/java/org/jabref/gui/groups/GroupNodeViewModelFilterTest.java b/jabgui/src/test/java/org/jabref/gui/groups/GroupNodeViewModelFilterTest.java new file mode 100644 index 00000000000..ab4bf5bd90e --- /dev/null +++ b/jabgui/src/test/java/org/jabref/gui/groups/GroupNodeViewModelFilterTest.java @@ -0,0 +1,82 @@ +package org.jabref.gui.groups; + +import org.jabref.logic.search.query.GroupNameFilterVisitor; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GroupNodeViewModelFilterTest { + + @ParameterizedTest + @CsvSource({ + "machine, machine learning, true", + "learning, machine learning, true", + "machine learning, machine learning, true", + "Neural Networks, machine learning, false", + "test group, test, true" + }) + void spaceImpliesOr(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "machine neural, machine AND neural, true", + "machine, machine AND neural, false", + "neural, machine AND neural, false" + }) + void explicitAndRequiresBothWords(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "machine, machine OR neural, true", + "neural, machine OR neural, true", + "unrelated, machine OR neural, false" + }) + void explicitOrWorks(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "machine, NOT machine, false", + "learning, NOT machine, true" + }) + void notWorks(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "Machine Learning, machine, true", + "machine learning, MACHINE, true" + }) + void caseInsensitiveMatch(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "Deep Learning, (deep OR neural) NOT vision, true", + "Neural Networks, (deep OR neural) NOT vision, true", + "Computer Vision, (deep OR neural) NOT vision, false", + "Machine Learning, (deep OR neural) NOT vision, false" + }) + void parenthesesWithNotWork(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } + + @ParameterizedTest + @CsvSource({ + "Computer Vision, (machine OR computer) NOT learning, true", + "Machine Learning, (machine OR computer) NOT learning, false", + "Neural Networks, (machine OR computer) NOT learning, false" + }) + void complexExpressionWithNotLearning(String groupName, String query, boolean expected) { + assertEquals(expected, GroupNameFilterVisitor.matches(groupName, query)); + } +} diff --git a/jablib/src/main/java/org/jabref/logic/search/query/GroupNameFilterVisitor.java b/jablib/src/main/java/org/jabref/logic/search/query/GroupNameFilterVisitor.java new file mode 100644 index 00000000000..21adcec166d --- /dev/null +++ b/jablib/src/main/java/org/jabref/logic/search/query/GroupNameFilterVisitor.java @@ -0,0 +1,104 @@ +package org.jabref.logic.search.query; + +import java.util.Locale; + +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.search.query.SearchQuery; +import org.jabref.search.SearchBaseVisitor; +import org.jabref.search.SearchParser; + +import org.antlr.v4.runtime.misc.ParseCancellationException; + +/// Evaluates a Search.g4 parse tree against a group display name string. +/// Key behavioral difference from {@link SearchQueryVisitor}: +/// {@code visitImplicitAndExpression} uses OR semantics (anyMatch) instead of AND, +/// so space-separated bare terms like "machine learning" match any group containing +/// either word. Explicit AND/OR/NOT and parentheses work as expected. +public class GroupNameFilterVisitor extends SearchBaseVisitor { + + private final String groupName; + + public GroupNameFilterVisitor(String groupName) { + // Lowercase for case-insensitive matching: typing "machine" matches "Machine Learning" + this.groupName = groupName.toLowerCase(Locale.ROOT); + } + + @Override + public Boolean visitStart(SearchParser.StartContext ctx) { + if (ctx.andExpression() == null) { + return true; + } + return visit(ctx.andExpression()); + } + + @Override + public Boolean visitImplicitAndExpression(SearchParser.ImplicitAndExpressionContext ctx) { + if (ctx.expression().size() == 1) { + return visit(ctx.expression().getFirst()); + } + boolean allSimpleTerm = ctx.expression().stream() + .allMatch(e -> e instanceof SearchParser.ComparisonExpressionContext); + if (allSimpleTerm) { + return ctx.expression().stream().anyMatch(this::visit); + } else { + return ctx.expression().stream().allMatch(this::visit); + } + } + + @Override + public Boolean visitBinaryExpression(SearchParser.BinaryExpressionContext ctx) { + boolean left = visit(ctx.left); + boolean right = visit(ctx.right); + return ctx.bin_op.getType() == SearchParser.AND + ? left && right + : left || right; + } + + @Override + public Boolean visitNegatedExpression(SearchParser.NegatedExpressionContext ctx) { + return !visit(ctx.expression()); + } + + @Override + public Boolean visitParenExpression(SearchParser.ParenExpressionContext ctx) { + return visit(ctx.andExpression()); + } + + @Override + public Boolean visitComparisonExpression(SearchParser.ComparisonExpressionContext ctx) { + return visit(ctx.comparison()); + } + + @Override + public Boolean visitComparison(SearchParser.ComparisonContext ctx) { + // Determine whether this is a simple term or a fielded/operator comparison. + // If the full node text differs from the searchValue text, we assume the + // presence of a field and/or operator (e.g., "name != learning"). + String fullText = ctx.getText(); + String valueText = ctx.searchValue().getText(); + + if (!fullText.equals(valueText)) { + // Fielded/operator comparisons are not supported by this visitor. + // Fall back to treating the entire comparison as a plain-text term, + // to avoid misinterpreting operators like "!=" as a positive match. + String plain = fullText.toLowerCase(Locale.ROOT); + return groupName.contains(plain); + } + + String term = SearchQueryConversion.unescapeSearchValue(ctx.searchValue()).toLowerCase(Locale.ROOT); + return groupName.contains(term); + } + + /// Implemented a static method that checks whether the group name matches the given query string. + public static boolean matches(String groupName, String query) { + if (StringUtil.isBlank(query)) { + return true; + } + try { + SearchParser.StartContext ctx = SearchQuery.getStartContext(query); + return new GroupNameFilterVisitor(groupName).visit(ctx); + } catch (ParseCancellationException e) { + return StringUtil.containsIgnoreCase(groupName, query); + } + } +}