Skip to content

Commit 36a2ae6

Browse files
committed
feat: Add "Add JabRef suggested groups" feature with tests
Add new feature to automatically create useful suggested groups: - "Entries without linked files" group to find entries missing file attachments - "Entries without groups" group to find entries not assigned to any group The feature is accessible through context menu on the "All entries" group. Add comprehensive test cases that verify: - Groups are correctly created with search expressions "file !=~.*" and "groups !=~.*" - Duplicate groups are not created when the feature is used multiple times - Only missing groups are added when some suggested groups already exist - Both scenarios are tested: adding missing "files" group and missing "groups" group - Changes are properly written to metadata - Selected groups are updated after adding suggested groups Add required localization strings to support this feature.
1 parent 2370cb7 commit 36a2ae6

File tree

9 files changed

+258
-3
lines changed

9 files changed

+258
-3
lines changed

src/main/java/org/jabref/gui/actions/StandardActions.java

+1
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ public enum StandardActions implements Action {
198198
GROUP_EDIT(Localization.lang("Edit group")),
199199
GROUP_GENERATE_SUMMARIES(Localization.lang("Generate summaries for entries in the group")),
200200
GROUP_GENERATE_EMBEDDINGS(Localization.lang("Generate embeddings for linked files in the group")),
201+
GROUP_SUGGESTED_GROUPS_ADD(Localization.lang("Add JabRef suggested groups")),
201202
GROUP_SUBGROUP_ADD(Localization.lang("Add subgroup")),
202203
GROUP_SUBGROUP_REMOVE(Localization.lang("Remove subgroups")),
203204
GROUP_SUBGROUP_SORT(Localization.lang("Sort subgroups A-Z")),

src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java

+36
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.jabref.gui.util.DroppingMouseLocation;
2929
import org.jabref.gui.util.UiTaskExecutor;
3030
import org.jabref.logic.groups.DefaultGroupsFactory;
31+
import org.jabref.logic.l10n.Localization;
3132
import org.jabref.logic.layout.format.LatexToUnicodeFormatter;
3233
import org.jabref.logic.util.BackgroundTask;
3334
import org.jabref.logic.util.TaskExecutor;
@@ -250,6 +251,41 @@ public GroupTreeNode getGroupNode() {
250251
return groupNode;
251252
}
252253

254+
/**
255+
* Checks if this group is the "All Entries" group.
256+
*
257+
* @return true if the group is an instance of AllEntriesGroup, false otherwise.
258+
*/
259+
public boolean isAllEntriesGroup() {
260+
return groupNode.getGroup() instanceof AllEntriesGroup;
261+
}
262+
263+
/**
264+
* Checks if all suggested groups already exist under this group.
265+
*
266+
* @return true if both "Entries without linked files" and "Entries without groups" already exist, false otherwise.
267+
*/
268+
public boolean hasSuggestedGroups() {
269+
if (!isAllEntriesGroup()) {
270+
return false;
271+
}
272+
273+
boolean hasEntriesWithoutFiles = false;
274+
boolean hasEntriesWithoutGroups = false;
275+
276+
for (GroupNodeViewModel child : getChildren()) {
277+
String name = child.getDisplayName();
278+
if (Localization.lang("Entries without linked files").equals(name)) {
279+
hasEntriesWithoutFiles = true;
280+
}
281+
if (Localization.lang("Entries without groups").equals(name)) {
282+
hasEntriesWithoutGroups = true;
283+
}
284+
}
285+
286+
return hasEntriesWithoutFiles && hasEntriesWithoutGroups;
287+
}
288+
253289
/**
254290
* Gets invoked if an entry in the current database changes.
255291
*

src/main/java/org/jabref/gui/groups/GroupTreeView.java

+5
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,7 @@ private ContextMenu createContextMenuForGroup(GroupNodeViewModel group) {
601601
factory.createMenuItem(StandardActions.GROUP_GENERATE_SUMMARIES, new ContextAction(StandardActions.GROUP_GENERATE_SUMMARIES, group)),
602602
removeGroup,
603603
new SeparatorMenuItem(),
604+
factory.createMenuItem(StandardActions.GROUP_SUGGESTED_GROUPS_ADD, new ContextAction(StandardActions.GROUP_SUGGESTED_GROUPS_ADD, group)),
604605
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_ADD, new ContextAction(StandardActions.GROUP_SUBGROUP_ADD, group)),
605606
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_RENAME, new ContextAction(StandardActions.GROUP_SUBGROUP_RENAME, group)),
606607
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_REMOVE, new ContextAction(StandardActions.GROUP_SUBGROUP_REMOVE, group)),
@@ -694,6 +695,8 @@ public ContextAction(StandardActions command, GroupNodeViewModel group) {
694695
group.isEditable();
695696
case GROUP_REMOVE, GROUP_REMOVE_WITH_SUBGROUPS, GROUP_REMOVE_KEEP_SUBGROUPS ->
696697
group.isEditable() && group.canRemove();
698+
case GROUP_SUGGESTED_GROUPS_ADD ->
699+
group.isAllEntriesGroup() && !group.hasSuggestedGroups();
697700
case GROUP_SUBGROUP_ADD ->
698701
group.isEditable() && group.canAddGroupsIn()
699702
|| group.isRoot();
@@ -729,6 +732,8 @@ public void execute() {
729732
viewModel.generateSummaries(group);
730733
case GROUP_CHAT ->
731734
viewModel.chatWithGroup(group);
735+
case GROUP_SUGGESTED_GROUPS_ADD ->
736+
viewModel.addSuggestedSubGroup(group);
732737
case GROUP_SUBGROUP_ADD ->
733738
viewModel.addNewSubgroup(group, GroupDialogHeader.SUBGROUP);
734739
case GROUP_SUBGROUP_REMOVE ->

src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java

+63
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.ArrayList;
44
import java.util.Collections;
55
import java.util.Comparator;
6+
import java.util.EnumSet;
67
import java.util.List;
78
import java.util.Objects;
89
import java.util.Optional;
@@ -37,12 +38,14 @@
3738
import org.jabref.model.groups.AutomaticKeywordGroup;
3839
import org.jabref.model.groups.AutomaticPersonsGroup;
3940
import org.jabref.model.groups.ExplicitGroup;
41+
import org.jabref.model.groups.GroupHierarchyType;
4042
import org.jabref.model.groups.GroupTreeNode;
4143
import org.jabref.model.groups.RegexKeywordGroup;
4244
import org.jabref.model.groups.SearchGroup;
4345
import org.jabref.model.groups.TexGroup;
4446
import org.jabref.model.groups.WordKeywordGroup;
4547
import org.jabref.model.metadata.MetaData;
48+
import org.jabref.model.search.SearchFlags;
4649

4750
import com.tobiasdiez.easybind.EasyBind;
4851
import dev.langchain4j.data.message.ChatMessage;
@@ -175,6 +178,66 @@ private void onActiveDatabaseChanged(Optional<BibDatabaseContext> newDatabase) {
175178
currentDatabase = newDatabase;
176179
}
177180

181+
/**
182+
* Adds JabRef suggested subgroups under the "All Entries" parent node.
183+
* Assumes the parent is already validated as "All Entries" by the caller.
184+
*
185+
* @param parent The "All Entries" parent node.
186+
*/
187+
public void addSuggestedSubGroup(GroupNodeViewModel parent) {
188+
currentDatabase.ifPresent(database -> {
189+
// Check for existing suggested subgroups to avoid duplicates
190+
boolean hasEntriesWithoutFiles = false;
191+
boolean hasEntriesWithoutGroups = false;
192+
for (GroupNodeViewModel child : parent.getChildren()) {
193+
String name = child.getGroupNode().getName();
194+
// Check if "Entries without linked files" already exists
195+
if (Localization.lang("Entries without linked files").equals(name)) {
196+
hasEntriesWithoutFiles = true;
197+
}
198+
// Check if "Entries without groups" already exists
199+
if (Localization.lang("Entries without groups").equals(name)) {
200+
hasEntriesWithoutGroups = true;
201+
}
202+
}
203+
204+
List<GroupTreeNode> newSubgroups = new ArrayList<>();
205+
206+
if (!hasEntriesWithoutFiles) {
207+
SearchGroup withoutFilesGroup = new SearchGroup(
208+
Localization.lang("Entries without linked files"),
209+
GroupHierarchyType.INDEPENDENT,
210+
"file !=~.*",
211+
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
212+
);
213+
GroupTreeNode newSubgroup = parent.addSubgroup(withoutFilesGroup);
214+
newSubgroups.add(newSubgroup);
215+
dialogService.notify(Localization.lang("Added group \"%0\".", withoutFilesGroup.getName()));
216+
}
217+
218+
if (!hasEntriesWithoutGroups) {
219+
SearchGroup withoutGroupsGroup = new SearchGroup(
220+
Localization.lang("Entries without groups"),
221+
GroupHierarchyType.INDEPENDENT,
222+
"groups !=~.*",
223+
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
224+
);
225+
GroupTreeNode newSubgroup = parent.addSubgroup(withoutGroupsGroup);
226+
newSubgroups.add(newSubgroup);
227+
dialogService.notify(Localization.lang("Added group \"%0\".", withoutGroupsGroup.getName()));
228+
}
229+
230+
if (!newSubgroups.isEmpty()) {
231+
selectedGroups.setAll(newSubgroups.stream()
232+
.map(node -> new GroupNodeViewModel(database, stateManager, taskExecutor, node, localDragboard, preferences))
233+
.collect(Collectors.toList()));
234+
writeGroupChangesToMetaData();
235+
} else {
236+
dialogService.notify(Localization.lang("All suggested groups already exist."));
237+
}
238+
});
239+
}
240+
178241
/**
179242
* Opens "New Group Dialog" and adds the resulting group as subgroup to the specified group
180243
*/

src/main/resources/csl-styles

Submodule csl-styles updated 46 files

src/main/resources/l10n/JabRef_en.properties

+5
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,14 @@ Add\ entry\ manually=Add entry manually
4343

4444
Add\ selected\ entries\ to\ this\ group=Add selected entries to this group
4545

46+
Add\ JabRef\ suggested\ groups=Add JabRef suggested groups
4647
Add\ subgroup=Add subgroup
4748
Rename\ subgroup=Rename subgroup
4849

50+
All\ suggested\ groups\ already\ exist.=All suggested groups already exist.
51+
Entries\ without\ groups=Entries without groups
52+
Entries\ without\ linked\ files=Entries without linked files
53+
4954
Added\ group\ "%0".=Added group "%0".
5055

5156
Added\ string\:\ '%0'=Added string: '%0'

src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java

+145
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package org.jabref.gui.groups;
22

33
import java.util.EnumSet;
4+
import java.util.List;
45
import java.util.Optional;
56

67
import org.jabref.gui.DialogService;
78
import org.jabref.gui.StateManager;
89
import org.jabref.gui.preferences.GuiPreferences;
910
import org.jabref.gui.util.CustomLocalDragboard;
1011
import org.jabref.logic.ai.AiService;
12+
import org.jabref.logic.l10n.Localization;
1113
import org.jabref.logic.util.CurrentThreadTaskExecutor;
1214
import org.jabref.logic.util.TaskExecutor;
1315
import org.jabref.model.database.BibDatabaseContext;
@@ -17,23 +19,31 @@
1719
import org.jabref.model.groups.AllEntriesGroup;
1820
import org.jabref.model.groups.ExplicitGroup;
1921
import org.jabref.model.groups.GroupHierarchyType;
22+
import org.jabref.model.groups.SearchGroup;
2023
import org.jabref.model.groups.WordKeywordGroup;
24+
import org.jabref.model.search.SearchFlags;
2125

2226
import org.junit.jupiter.api.BeforeEach;
2327
import org.junit.jupiter.api.Test;
2428
import org.mockito.Answers;
29+
import org.mockito.ArgumentCaptor;
30+
import org.mockito.Mockito;
2531

2632
import static org.junit.jupiter.api.Assertions.assertEquals;
2733
import static org.junit.jupiter.api.Assertions.assertFalse;
2834
import static org.junit.jupiter.api.Assertions.assertTrue;
35+
import static org.mockito.ArgumentMatchers.anyString;
2936
import static org.mockito.Mockito.mock;
37+
import static org.mockito.Mockito.times;
38+
import static org.mockito.Mockito.verify;
3039
import static org.mockito.Mockito.when;
3140

3241
class GroupTreeViewModelTest {
3342

3443
private StateManager stateManager;
3544
private GroupTreeViewModel groupTree;
3645
private BibDatabaseContext databaseContext;
46+
private GroupNodeViewModel rootGroupViewModel;
3747
private TaskExecutor taskExecutor;
3848
private GuiPreferences preferences;
3949
private DialogService dialogService;
@@ -53,6 +63,8 @@ void setUp() {
5363
true,
5464
GroupHierarchyType.INDEPENDENT));
5565
groupTree = new GroupTreeViewModel(stateManager, mock(DialogService.class), mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
66+
67+
rootGroupViewModel = groupTree.rootGroupProperty().get();
5668
}
5769

5870
@Test
@@ -139,4 +151,137 @@ void shouldShowDialogWhenCaseSensitivyDiffers() {
139151
GroupTreeViewModel model = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
140152
assertFalse(model.onlyMinorChanges(oldGroup, newGroup));
141153
}
154+
155+
@Test
156+
void addSuggestedSubGroupCreatesCorrectGroups() {
157+
Mockito.reset(dialogService);
158+
159+
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
160+
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();
161+
162+
testGroupTree.addSuggestedSubGroup(testRootGroup);
163+
164+
verify(dialogService, times(2)).notify(anyString());
165+
166+
List<GroupNodeViewModel> children = testRootGroup.getChildren();
167+
168+
assertEquals(2, children.size());
169+
170+
GroupNodeViewModel firstGroup = children.get(0);
171+
assertEquals(Localization.lang("Entries without linked files"), firstGroup.getDisplayName());
172+
173+
GroupNodeViewModel secondGroup = children.get(1);
174+
assertEquals(Localization.lang("Entries without groups"), secondGroup.getDisplayName());
175+
176+
AbstractGroup firstGroupObj = firstGroup.getGroupNode().getGroup();
177+
assertTrue(firstGroupObj instanceof SearchGroup);
178+
SearchGroup firstSearchGroup = (SearchGroup) firstGroupObj;
179+
assertEquals("file !=~.*", firstSearchGroup.getSearchExpression());
180+
assertTrue(firstSearchGroup.getSearchFlags().contains(SearchFlags.CASE_INSENSITIVE));
181+
182+
AbstractGroup secondGroupObj = secondGroup.getGroupNode().getGroup();
183+
assertTrue(secondGroupObj instanceof SearchGroup);
184+
SearchGroup secondSearchGroup = (SearchGroup) secondGroupObj;
185+
assertEquals("groups !=~.*", secondSearchGroup.getSearchExpression());
186+
assertTrue(secondSearchGroup.getSearchFlags().contains(SearchFlags.CASE_INSENSITIVE));
187+
}
188+
189+
@Test
190+
void addSuggestedSubGroupDoesNotCreateDuplicateGroups() {
191+
Mockito.reset(dialogService);
192+
193+
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
194+
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();
195+
196+
testGroupTree.addSuggestedSubGroup(testRootGroup);
197+
198+
Mockito.reset(dialogService);
199+
200+
testGroupTree.addSuggestedSubGroup(testRootGroup);
201+
202+
ArgumentCaptor<String> messageCaptor = ArgumentCaptor.forClass(String.class);
203+
verify(dialogService, times(1)).notify(messageCaptor.capture());
204+
assertEquals(Localization.lang("All suggested groups already exist."), messageCaptor.getValue());
205+
206+
List<GroupNodeViewModel> children = testRootGroup.getChildren();
207+
assertEquals(2, children.size());
208+
}
209+
210+
@Test
211+
void addSuggestedSubGroupWritesChangesToMetaData() {
212+
GroupTreeViewModel spyGroupTree = Mockito.spy(groupTree);
213+
214+
spyGroupTree.addSuggestedSubGroup(rootGroupViewModel);
215+
216+
verify(spyGroupTree).writeGroupChangesToMetaData();
217+
}
218+
219+
@Test
220+
void addSuggestedSubGroupAddsOnlyMissingFilesGroup() {
221+
Mockito.reset(dialogService);
222+
223+
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
224+
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();
225+
226+
SearchGroup withoutGroupsGroup = new SearchGroup(
227+
Localization.lang("Entries without groups"),
228+
GroupHierarchyType.INDEPENDENT,
229+
"groups !=~.*",
230+
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
231+
);
232+
testRootGroup.addSubgroup(withoutGroupsGroup);
233+
234+
assertEquals(1, testRootGroup.getChildren().size());
235+
236+
testGroupTree.addSuggestedSubGroup(testRootGroup);
237+
238+
verify(dialogService, times(1)).notify(anyString());
239+
240+
List<GroupNodeViewModel> children = testRootGroup.getChildren();
241+
assertEquals(2, children.size());
242+
243+
boolean hasWithoutFilesGroup = children.stream()
244+
.anyMatch(group -> group.getDisplayName().equals(Localization.lang("Entries without linked files")));
245+
assertTrue(hasWithoutFilesGroup);
246+
}
247+
248+
@Test
249+
void addSuggestedSubGroupAddsOnlyMissingGroupsGroup() {
250+
Mockito.reset(dialogService);
251+
252+
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
253+
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();
254+
255+
SearchGroup withoutFilesGroup = new SearchGroup(
256+
Localization.lang("Entries without linked files"),
257+
GroupHierarchyType.INDEPENDENT,
258+
"file !=~.*",
259+
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
260+
);
261+
testRootGroup.addSubgroup(withoutFilesGroup);
262+
263+
assertEquals(1, testRootGroup.getChildren().size());
264+
265+
testGroupTree.addSuggestedSubGroup(testRootGroup);
266+
267+
verify(dialogService, times(1)).notify(anyString());
268+
269+
List<GroupNodeViewModel> children = testRootGroup.getChildren();
270+
assertEquals(2, children.size());
271+
272+
boolean hasWithoutGroupsGroup = children.stream()
273+
.anyMatch(group -> group.getDisplayName().equals(Localization.lang("Entries without groups")));
274+
assertTrue(hasWithoutGroupsGroup);
275+
}
276+
277+
@Test
278+
void addSuggestedSubGroupUpdatesSelectedGroups() {
279+
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
280+
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();
281+
282+
testGroupTree.addSuggestedSubGroup(testRootGroup);
283+
284+
assertFalse(testGroupTree.selectedGroupsProperty().isEmpty());
285+
assertEquals(2, testGroupTree.selectedGroupsProperty().size());
286+
}
142287
}

0 commit comments

Comments
 (0)