diff --git a/CHANGELOG.md b/CHANGELOG.md index 049d6112641..2cd54489b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/main/java/org/jabref/gui/actions/StandardActions.java b/src/main/java/org/jabref/gui/actions/StandardActions.java index 49c84c384c8..028f5897bc1 100644 --- a/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -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")), diff --git a/src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java b/src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java index 718fc45229d..378273c1cee 100644 --- a/src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java +++ b/src/main/java/org/jabref/gui/groups/GroupNodeViewModel.java @@ -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; @@ -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. * diff --git a/src/main/java/org/jabref/gui/groups/GroupTreeView.java b/src/main/java/org/jabref/gui/groups/GroupTreeView.java index 9186d488a46..e30e1bb284d 100644 --- a/src/main/java/org/jabref/gui/groups/GroupTreeView.java +++ b/src/main/java/org/jabref/gui/groups/GroupTreeView.java @@ -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)), @@ -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(); @@ -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 -> diff --git a/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java b/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java index 48def6325c8..48c8df0ddf2 100644 --- a/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java +++ b/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java @@ -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; @@ -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 rootGroup = new SimpleObjectProperty<>(); private final ListProperty selectedGroups = new SimpleListProperty<>(FXCollections.observableArrayList()); private final StateManager stateManager; @@ -175,6 +177,66 @@ private void onActiveDatabaseChanged(Optional 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 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 */ diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index a7737a9a456..af1955cb00d 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -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' diff --git a/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java b/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java index fd3b781a4b6..fe48cc191eb 100644 --- a/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java +++ b/src/test/java/org/jabref/gui/groups/GroupTreeViewModelTest.java @@ -1,6 +1,7 @@ package org.jabref.gui.groups; import java.util.EnumSet; +import java.util.List; import java.util.Optional; import org.jabref.gui.DialogService; @@ -8,6 +9,7 @@ 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; @@ -17,16 +19,24 @@ 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 { @@ -34,6 +44,7 @@ class GroupTreeViewModelTest { private StateManager stateManager; private GroupTreeViewModel groupTree; private BibDatabaseContext databaseContext; + private GroupNodeViewModel rootGroupViewModel; private TaskExecutor taskExecutor; private GuiPreferences preferences; private DialogService dialogService; @@ -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 @@ -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 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 messageCaptor = ArgumentCaptor.forClass(String.class); + verify(dialogService, times(1)).notify(messageCaptor.capture()); + assertEquals(Localization.lang("All suggested groups already exist."), messageCaptor.getValue()); + + List 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 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 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()); + } }