Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "Add JabRef suggested groups" #12746

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added a new "Export to clipboard" button in the context menu of the preview. [#12551](https://github.com/JabRef/jabref/issues/12551)
- We added an integrity check if a URL appears in a title. [#12354](https://github.com/JabRef/jabref/issues/12354)
- We added a feature for enabling drag-and-drop of files into groups [#12540](https://github.com/JabRef/jabref/issues/12540)
- We added a new "Add JabRef suggested groups" option in the context menu of "All entries". [#12659](https://github.com/JabRef/jabref/issues/12659)
- We added support for reordering keywords via drag and drop, automatic alphabetical ordering, and improved pasting and editing functionalities in the keyword editor. [#10984](https://github.com/JabRef/jabref/issues/10984)

### Changed
Expand Down
1 change: 1 addition & 0 deletions src/main/java/org/jabref/gui/actions/StandardActions.java
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ public enum StandardActions implements Action {
GROUP_EDIT(Localization.lang("Edit group")),
GROUP_GENERATE_SUMMARIES(Localization.lang("Generate summaries for entries in the group")),
GROUP_GENERATE_EMBEDDINGS(Localization.lang("Generate embeddings for linked files in the group")),
GROUP_SUGGESTED_GROUPS_ADD(Localization.lang("Add JabRef suggested groups")),
GROUP_SUBGROUP_ADD(Localization.lang("Add subgroup")),
GROUP_SUBGROUP_REMOVE(Localization.lang("Remove subgroups")),
GROUP_SUBGROUP_SORT(Localization.lang("Sort subgroups A-Z")),
Expand Down
31 changes: 31 additions & 0 deletions src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import org.jabref.gui.util.DroppingMouseLocation;
import org.jabref.gui.util.UiTaskExecutor;
import org.jabref.logic.groups.DefaultGroupsFactory;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.layout.format.LatexToUnicodeFormatter;
import org.jabref.logic.util.BackgroundTask;
import org.jabref.logic.util.TaskExecutor;
Expand Down Expand Up @@ -250,6 +251,36 @@ public GroupTreeNode getGroupNode() {
return groupNode;
}

public boolean isAllEntriesGroup() {
return groupNode.getGroup() instanceof AllEntriesGroup;
}

/**
* Checks if all suggested groups already exist under this group.
*
* @return true if both "Entries without linked files" and "Entries without groups" already exist, false otherwise.
*/
public boolean hasSuggestedGroups() {
if (!isAllEntriesGroup()) {
return false;
}

boolean hasEntriesWithoutFiles = false;
boolean hasEntriesWithoutGroups = false;

for (GroupNodeViewModel child : getChildren()) {
String name = child.getDisplayName();
if (Localization.lang("Entries without linked files").equals(name)) {
hasEntriesWithoutFiles = true;
}
if (Localization.lang("Entries without groups").equals(name)) {
hasEntriesWithoutGroups = true;
}
}

return hasEntriesWithoutFiles && hasEntriesWithoutGroups;
}

/**
* Gets invoked if an entry in the current database changes.
*
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/org/jabref/gui/groups/GroupTreeView.java
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@ private ContextMenu createContextMenuForGroup(GroupNodeViewModel group) {
factory.createMenuItem(StandardActions.GROUP_GENERATE_SUMMARIES, new ContextAction(StandardActions.GROUP_GENERATE_SUMMARIES, group)),
removeGroup,
new SeparatorMenuItem(),
factory.createMenuItem(StandardActions.GROUP_SUGGESTED_GROUPS_ADD, new ContextAction(StandardActions.GROUP_SUGGESTED_GROUPS_ADD, group)),
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_ADD, new ContextAction(StandardActions.GROUP_SUBGROUP_ADD, group)),
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_RENAME, new ContextAction(StandardActions.GROUP_SUBGROUP_RENAME, group)),
factory.createMenuItem(StandardActions.GROUP_SUBGROUP_REMOVE, new ContextAction(StandardActions.GROUP_SUBGROUP_REMOVE, group)),
Expand Down Expand Up @@ -694,6 +695,8 @@ public ContextAction(StandardActions command, GroupNodeViewModel group) {
group.isEditable();
case GROUP_REMOVE, GROUP_REMOVE_WITH_SUBGROUPS, GROUP_REMOVE_KEEP_SUBGROUPS ->
group.isEditable() && group.canRemove();
case GROUP_SUGGESTED_GROUPS_ADD ->
group.isAllEntriesGroup() && !group.hasSuggestedGroups();
case GROUP_SUBGROUP_ADD ->
group.isEditable() && group.canAddGroupsIn()
|| group.isRoot();
Expand Down Expand Up @@ -729,6 +732,8 @@ public void execute() {
viewModel.generateSummaries(group);
case GROUP_CHAT ->
viewModel.chatWithGroup(group);
case GROUP_SUGGESTED_GROUPS_ADD ->
viewModel.addSuggestedSubGroup(group);
case GROUP_SUBGROUP_ADD ->
viewModel.addNewSubgroup(group, GroupDialogHeader.SUBGROUP);
case GROUP_SUBGROUP_REMOVE ->
Expand Down
64 changes: 63 additions & 1 deletion src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
Expand Down Expand Up @@ -37,18 +38,19 @@
import org.jabref.model.groups.AutomaticKeywordGroup;
import org.jabref.model.groups.AutomaticPersonsGroup;
import org.jabref.model.groups.ExplicitGroup;
import org.jabref.model.groups.GroupHierarchyType;
import org.jabref.model.groups.GroupTreeNode;
import org.jabref.model.groups.RegexKeywordGroup;
import org.jabref.model.groups.SearchGroup;
import org.jabref.model.groups.TexGroup;
import org.jabref.model.groups.WordKeywordGroup;
import org.jabref.model.metadata.MetaData;
import org.jabref.model.search.SearchFlags;

import com.tobiasdiez.easybind.EasyBind;
import dev.langchain4j.data.message.ChatMessage;

public class GroupTreeViewModel extends AbstractViewModel {

private final ObjectProperty<GroupNodeViewModel> rootGroup = new SimpleObjectProperty<>();
private final ListProperty<GroupNodeViewModel> selectedGroups = new SimpleListProperty<>(FXCollections.observableArrayList());
private final StateManager stateManager;
Expand Down Expand Up @@ -175,6 +177,66 @@ private void onActiveDatabaseChanged(Optional<BibDatabaseContext> newDatabase) {
currentDatabase = newDatabase;
}

/**
* Adds JabRef suggested subgroups under the "All Entries" parent node.
* Assumes the parent is already validated as "All Entries" by the caller.
*
* @param parent The "All Entries" parent node.
*/
public void addSuggestedSubGroup(GroupNodeViewModel parent) {
currentDatabase.ifPresent(database -> {
// Check for existing suggested subgroups to avoid duplicates
boolean hasEntriesWithoutFiles = false;
boolean hasEntriesWithoutGroups = false;
for (GroupNodeViewModel child : parent.getChildren()) {
String name = child.getGroupNode().getName();
// Check if "Entries without linked files" already exists
if (Localization.lang("Entries without linked files").equals(name)) {
hasEntriesWithoutFiles = true;
}
// Check if "Entries without groups" already exists
if (Localization.lang("Entries without groups").equals(name)) {
hasEntriesWithoutGroups = true;
}
}

List<GroupTreeNode> newSubgroups = new ArrayList<>();

if (!hasEntriesWithoutFiles) {
SearchGroup withoutFilesGroup = new SearchGroup(
Localization.lang("Entries without linked files"),
GroupHierarchyType.INDEPENDENT,
"file !=~.*",
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
);
GroupTreeNode newSubgroup = parent.addSubgroup(withoutFilesGroup);
newSubgroups.add(newSubgroup);
dialogService.notify(Localization.lang("Added group \"%0\".", withoutFilesGroup.getName()));
}

if (!hasEntriesWithoutGroups) {
SearchGroup withoutGroupsGroup = new SearchGroup(
Localization.lang("Entries without groups"),
GroupHierarchyType.INDEPENDENT,
"groups !=~.*",
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
);
GroupTreeNode newSubgroup = parent.addSubgroup(withoutGroupsGroup);
newSubgroups.add(newSubgroup);
dialogService.notify(Localization.lang("Added group \"%0\".", withoutGroupsGroup.getName()));
}

if (!newSubgroups.isEmpty()) {
selectedGroups.setAll(newSubgroups.stream()
.map(node -> new GroupNodeViewModel(database, stateManager, taskExecutor, node, localDragboard, preferences))
.collect(Collectors.toList()));
writeGroupChangesToMetaData();
} else {
dialogService.notify(Localization.lang("All suggested groups already exist."));
}
});
}

/**
* Opens "New Group Dialog" and adds the resulting group as subgroup to the specified group
*/
Expand Down
5 changes: 5 additions & 0 deletions src/main/resources/l10n/JabRef_en.properties
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,14 @@ Add\ entry\ manually=Add entry manually

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

Add\ JabRef\ suggested\ groups=Add JabRef suggested groups
Add\ subgroup=Add subgroup
Rename\ subgroup=Rename subgroup

All\ suggested\ groups\ already\ exist.=All suggested groups already exist.
Entries\ without\ groups=Entries without groups
Entries\ without\ linked\ files=Entries without linked files

Added\ group\ "%0".=Added group "%0".

Added\ string\:\ '%0'=Added string: '%0'
Expand Down
145 changes: 145 additions & 0 deletions src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package org.jabref.gui.groups;

import java.util.EnumSet;
import java.util.List;
import java.util.Optional;

import org.jabref.gui.DialogService;
import org.jabref.gui.StateManager;
import org.jabref.gui.preferences.GuiPreferences;
import org.jabref.gui.util.CustomLocalDragboard;
import org.jabref.logic.ai.AiService;
import org.jabref.logic.l10n.Localization;
import org.jabref.logic.util.CurrentThreadTaskExecutor;
import org.jabref.logic.util.TaskExecutor;
import org.jabref.model.database.BibDatabaseContext;
Expand All @@ -17,23 +19,32 @@
import org.jabref.model.groups.AllEntriesGroup;
import org.jabref.model.groups.ExplicitGroup;
import org.jabref.model.groups.GroupHierarchyType;
import org.jabref.model.groups.SearchGroup;
import org.jabref.model.groups.WordKeywordGroup;
import org.jabref.model.search.SearchFlags;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Answers;
import org.mockito.ArgumentCaptor;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

class GroupTreeViewModelTest {

private StateManager stateManager;
private GroupTreeViewModel groupTree;
private BibDatabaseContext databaseContext;
private GroupNodeViewModel rootGroupViewModel;
private TaskExecutor taskExecutor;
private GuiPreferences preferences;
private DialogService dialogService;
Expand All @@ -53,6 +64,7 @@ void setUp() {
true,
GroupHierarchyType.INDEPENDENT));
groupTree = new GroupTreeViewModel(stateManager, mock(DialogService.class), mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
rootGroupViewModel = groupTree.rootGroupProperty().get();
}

@Test
Expand Down Expand Up @@ -139,4 +151,137 @@ void shouldShowDialogWhenCaseSensitivyDiffers() {
GroupTreeViewModel model = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
assertFalse(model.onlyMinorChanges(oldGroup, newGroup));
}

@Test
void addSuggestedSubGroupCreatesCorrectGroups() {
Mockito.reset(dialogService);

GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();

testGroupTree.addSuggestedSubGroup(testRootGroup);

verify(dialogService, times(2)).notify(anyString());

List<GroupNodeViewModel> children = testRootGroup.getChildren();

assertEquals(2, children.size());

GroupNodeViewModel firstGroup = children.getFirst();
assertEquals(Localization.lang("Entries without linked files"), firstGroup.getDisplayName());

GroupNodeViewModel secondGroup = children.get(1);
assertEquals(Localization.lang("Entries without groups"), secondGroup.getDisplayName());

AbstractGroup firstGroupObj = firstGroup.getGroupNode().getGroup();
assertInstanceOf(SearchGroup.class, firstGroupObj);
SearchGroup firstSearchGroup = (SearchGroup) firstGroupObj;
assertEquals("file !=~.*", firstSearchGroup.getSearchExpression());
assertEquals(EnumSet.of(SearchFlags.CASE_INSENSITIVE), firstSearchGroup.getSearchFlags());

AbstractGroup secondGroupObj = secondGroup.getGroupNode().getGroup();
assertInstanceOf(SearchGroup.class, secondGroupObj);
SearchGroup secondSearchGroup = (SearchGroup) secondGroupObj;
assertEquals("groups !=~.*", secondSearchGroup.getSearchExpression());
assertEquals(EnumSet.of(SearchFlags.CASE_INSENSITIVE), secondSearchGroup.getSearchFlags());
}

@Test
void addSuggestedSubGroupDoesNotCreateDuplicateGroups() {
Mockito.reset(dialogService);

GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();

testGroupTree.addSuggestedSubGroup(testRootGroup);

Mockito.reset(dialogService);

testGroupTree.addSuggestedSubGroup(testRootGroup);

ArgumentCaptor<String> messageCaptor = ArgumentCaptor.forClass(String.class);
verify(dialogService, times(1)).notify(messageCaptor.capture());
assertEquals(Localization.lang("All suggested groups already exist."), messageCaptor.getValue());

List<GroupNodeViewModel> children = testRootGroup.getChildren();
assertEquals(2, children.size());
}

@Test
void addSuggestedSubGroupWritesChangesToMetaData() {
GroupTreeViewModel spyGroupTree = Mockito.spy(groupTree);

spyGroupTree.addSuggestedSubGroup(rootGroupViewModel);

verify(spyGroupTree).writeGroupChangesToMetaData();
}

@Test
void addSuggestedSubGroupAddsOnlyMissingFilesGroup() {
Mockito.reset(dialogService);

GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();

SearchGroup withoutGroupsGroup = new SearchGroup(
Localization.lang("Entries without groups"),
GroupHierarchyType.INDEPENDENT,
"groups !=~.*",
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
);
testRootGroup.addSubgroup(withoutGroupsGroup);

assertEquals(1, testRootGroup.getChildren().size());

testGroupTree.addSuggestedSubGroup(testRootGroup);

verify(dialogService, times(1)).notify(anyString());

List<GroupNodeViewModel> children = testRootGroup.getChildren();
assertEquals(2, children.size());

boolean hasWithoutFilesGroup = children.stream()
.anyMatch(group -> group.getDisplayName().equals(Localization.lang("Entries without linked files")));
assertTrue(hasWithoutFilesGroup);
}

@Test
void addSuggestedSubGroupAddsOnlyMissingGroupsGroup() {
Mockito.reset(dialogService);

GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();

SearchGroup withoutFilesGroup = new SearchGroup(
Localization.lang("Entries without linked files"),
GroupHierarchyType.INDEPENDENT,
"file !=~.*",
EnumSet.of(SearchFlags.CASE_INSENSITIVE)
);
testRootGroup.addSubgroup(withoutFilesGroup);

assertEquals(1, testRootGroup.getChildren().size());

testGroupTree.addSuggestedSubGroup(testRootGroup);

verify(dialogService, times(1)).notify(anyString());

List<GroupNodeViewModel> children = testRootGroup.getChildren();
assertEquals(2, children.size());

boolean hasWithoutGroupsGroup = children.stream()
.anyMatch(group -> group.getDisplayName().equals(Localization.lang("Entries without groups")));
assertTrue(hasWithoutGroupsGroup);
}

@Test
void addSuggestedSubGroupUpdatesSelectedGroups() {
GroupTreeViewModel testGroupTree = new GroupTreeViewModel(stateManager, dialogService, mock(AiService.class), preferences, taskExecutor, new CustomLocalDragboard());
GroupNodeViewModel testRootGroup = testGroupTree.rootGroupProperty().get();

testGroupTree.addSuggestedSubGroup(testRootGroup);

assertFalse(testGroupTree.selectedGroupsProperty().isEmpty());
assertEquals(2, testGroupTree.selectedGroupsProperty().size());
}
}
Loading