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)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Localization.lang("Entries without linked files") should be private static final constant.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very weird to depend on the name of the group. Can't we set some id or something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree—relying on the group’s name like Localization.lang("Entries without linked files") does feel shaky, especially with localization involved. Using an id or something similar, as you suggested, seems way more robust.

I was thinking about adding an enum GroupType to GroupNodeViewModel, like WITHOUT_LINKED_FILES, to keep the logic clean and separate from the display. It’d need a one-time migration for existing data, though. A boolean flag crossed my mind too, but I’m not sure it’d scale well if we add more group types later.

If you have any good suggestions, I’d greatly appreciate your input!

hasEntriesWithoutFiles = true;
}
if (Localization.lang("Entries without groups").equals(name)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Localization.lang should be private static final constant.

Moreover use } else if ..., because only one of the conditions can match

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
65 changes: 65 additions & 0 deletions 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,17 +38,21 @@
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 static final String ENTRIES_WITHOUT_LINKED_FILES = Localization.lang("Entries without linked files");
private static final String ENTRIES_WITHOUT_GROUPS = Localization.lang("Entries without groups");

private final ObjectProperty<GroupNodeViewModel> rootGroup = new SimpleObjectProperty<>();
private final ListProperty<GroupNodeViewModel> selectedGroups = new SimpleListProperty<>(FXCollections.observableArrayList());
Expand Down Expand Up @@ -175,6 +180,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;
}
}
Comment on lines +188 to +201
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this existing? I think, this method can only be called if the action is availble?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the check could be helpful since the method needs to handle cases where only one group exists, per issue #12659’s requirements. I’m wondering if there’s a way to adjust the logic or preconditions to make it simpler—do you have any suggestions?


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

if (!hasEntriesWithoutFiles) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not necessary, is it? (See comment above)

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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If not necessary, is it? (See comment above)

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."));
}
Comment on lines +234 to +236
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary, is it? (See comment above)

});
}

/**
* 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
144 changes: 144 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,31 @@
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.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 +63,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 +150,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.get(0);
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();
assertTrue(firstGroupObj instanceof SearchGroup);
SearchGroup firstSearchGroup = (SearchGroup) firstGroupObj;
assertEquals("file !=~.*", firstSearchGroup.getSearchExpression());
assertTrue(firstSearchGroup.getSearchFlags().contains(SearchFlags.CASE_INSENSITIVE));

AbstractGroup secondGroupObj = secondGroup.getGroupNode().getGroup();
assertTrue(secondGroupObj instanceof SearchGroup);
SearchGroup secondSearchGroup = (SearchGroup) secondGroupObj;
assertEquals("groups !=~.*", secondSearchGroup.getSearchExpression());
assertTrue(secondSearchGroup.getSearchFlags().contains(SearchFlags.CASE_INSENSITIVE));
}

@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