diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdec74cc2f..d2b106501b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv - We added a new ID based fetcher for [EuropePMC](https://europepmc.org/). [#13389](https://github.com/JabRef/jabref/pull/13389) - We added quick settings for welcome tab. [#12664](https://github.com/JabRef/jabref/issues/12664) - We added an initial [cite as you write](https://retorque.re/zotero-better-bibtex/citing/cayw/) endpoint. [#13187](https://github.com/JabRef/jabref/issues/13187) +- We added pagination support for the web search entries dialog, improving navigation for large search results. [#5507](https://github.com/JabRef/jabref/issues/5507) - We added "copy preview as markdown" feature. [#12552](https://github.com/JabRef/jabref/issues/12552) - In case no citation relation information can be fetched, we show the data providers reason. [#13549](https://github.com/JabRef/jabref/pull/13549) - When relativizing file names, symlinks are now taken into account. [#12995](https://github.com/JabRef/jabref/issues/12995) diff --git a/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesDialog.java b/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesDialog.java index 4fc5a89000e..03492d4cece 100644 --- a/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesDialog.java @@ -6,9 +6,12 @@ import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.BooleanProperty; +import javafx.collections.ListChangeListener; import javafx.css.PseudoClass; import javafx.fxml.FXML; import javafx.geometry.Insets; +import javafx.scene.Cursor; import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.ButtonType; @@ -35,7 +38,9 @@ import org.jabref.gui.util.BaseDialog; import org.jabref.gui.util.NoSelectionModel; import org.jabref.gui.util.ViewModelListCellFactory; +import org.jabref.logic.importer.PagedSearchBasedFetcher; import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.SearchBasedFetcher; import org.jabref.logic.l10n.Localization; import org.jabref.logic.shared.DatabaseLocation; import org.jabref.logic.util.BackgroundTask; @@ -53,7 +58,8 @@ import org.fxmisc.richtext.CodeArea; public class ImportEntriesDialog extends BaseDialog { - + @FXML private HBox paginationBox; + @FXML private Label pageNumberLabel; @FXML private CheckListView entriesListView; @FXML private ComboBox libraryListView; @FXML private ButtonType importButton; @@ -64,10 +70,15 @@ public class ImportEntriesDialog extends BaseDialog { @FXML private CheckBox showEntryInformation; @FXML private CodeArea bibTeXData; @FXML private VBox bibTeXDataBox; + @FXML private Button nextPageButton; + @FXML private Button prevPageButton; + @FXML private Label statusLabel; private final BackgroundTask task; private final BibDatabaseContext database; private ImportEntriesViewModel viewModel; + private final Optional searchBasedFetcher; + private final Optional query; @Inject private TaskExecutor taskExecutor; @Inject private DialogService dialogService; @@ -78,7 +89,9 @@ public class ImportEntriesDialog extends BaseDialog { @Inject private FileUpdateMonitor fileUpdateMonitor; /** - * Imports the given entries into the given database. The entries are provided using the BackgroundTask + * Creates an import dialog for entries from file sources. + * This constructor is used for importing entries from local files, BibTeX files, + * or other file-based sources that don't require pagination or search functionality. * * @param database the database to import into * @param task the task executed for parsing the selected files(s). @@ -86,34 +99,51 @@ public class ImportEntriesDialog extends BaseDialog { public ImportEntriesDialog(BibDatabaseContext database, BackgroundTask task) { this.database = database; this.task = task; - ViewLoader.view(this) - .load() - .setAsDialogPane(this); + this.searchBasedFetcher = Optional.empty(); + this.query = Optional.empty(); - BooleanBinding booleanBind = Bindings.isEmpty(entriesListView.getCheckModel().getCheckedItems()); - Button btn = (Button) this.getDialogPane().lookupButton(importButton); - btn.disableProperty().bind(booleanBind); - - downloadLinkedOnlineFiles.setSelected(preferences.getFilePreferences().shouldDownloadLinkedFiles()); + initializeDialog(); + } - setResultConverter(button -> { - if (button == importButton) { - viewModel.importEntries(entriesListView.getCheckModel().getCheckedItems(), downloadLinkedOnlineFiles.isSelected()); - } else { - dialogService.notify(Localization.lang("Import canceled")); - } + /** + * Creates an import dialog for entries from web-based search sources. + * This constructor is used for importing entries that support pagination and require search queries. + * + * @param database database where the imported entries will be added + * @param task task that handles parsing and loading entries from the search results + * @param fetcher the search-based fetcher implementation used to retrieve entries from the web source + * @param query the search string used to find relevant entries + */ + public ImportEntriesDialog(BibDatabaseContext database, BackgroundTask task, SearchBasedFetcher fetcher, String query) { + this.database = database; + this.task = task; + this.searchBasedFetcher = Optional.of(fetcher); + this.query = Optional.of(query); - return false; - }); + initializeDialog(); } @FXML private void initialize() { - viewModel = new ImportEntriesViewModel(task, taskExecutor, database, dialogService, undoManager, preferences, stateManager, entryTypesManager, fileUpdateMonitor); + viewModel = new ImportEntriesViewModel(task, taskExecutor, database, dialogService, undoManager, preferences, stateManager, entryTypesManager, fileUpdateMonitor, searchBasedFetcher, query); Label placeholder = new Label(); placeholder.textProperty().bind(viewModel.messageProperty()); entriesListView.setPlaceholder(placeholder); entriesListView.setItems(viewModel.getEntries()); + entriesListView.getCheckModel().getCheckedItems().addListener((ListChangeListener) change -> { + while (change.next()) { + if (change.wasAdded()) { + for (BibEntry entry : change.getAddedSubList()) { + viewModel.getCheckedEntries().add(entry); + } + } + if (change.wasRemoved()) { + for (BibEntry entry : change.getRemoved()) { + viewModel.getCheckedEntries().remove(entry); + } + } + } + }); libraryListView.setEditable(false); libraryListView.getItems().addAll(stateManager.getOpenDatabases()); @@ -180,14 +210,144 @@ private void initialize() { .withPseudoClass(entrySelected, entriesListView::getItemBooleanProperty) .install(entriesListView); - selectedItems.textProperty().bind(Bindings.size(entriesListView.getCheckModel().getCheckedItems()).asString()); - totalItems.textProperty().bind(Bindings.size(entriesListView.getItems()).asString()); + selectedItems.textProperty().bind(Bindings.size(viewModel.getCheckedEntries()).asString()); + totalItems.textProperty().bind(Bindings.size(viewModel.getAllEntries()).asString()); entriesListView.setSelectionModel(new NoSelectionModel<>()); initBibTeX(); + if (searchBasedFetcher.isPresent()) { + updatePageUI(); + setupPaginationBindings(); + } + } + + private void initializeDialog() { + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + + paginationBox.setVisible(searchBasedFetcher.isPresent()); + paginationBox.setManaged(searchBasedFetcher.isPresent()); + + BooleanBinding booleanBind = Bindings.isEmpty(entriesListView.getCheckModel().getCheckedItems()); + Button btn = (Button) this.getDialogPane().lookupButton(importButton); + btn.disableProperty().bind(booleanBind); + + downloadLinkedOnlineFiles.setSelected(preferences.getFilePreferences().shouldDownloadLinkedFiles()); + + setResultConverter(button -> { + if (button == importButton) { + viewModel.importEntries(viewModel.getCheckedEntries().stream().toList(), downloadLinkedOnlineFiles.isSelected()); + } else { + dialogService.notify(Localization.lang("Import canceled")); + } + + return false; + }); + } + + private void setupPaginationBindings() { + BooleanProperty loading = viewModel.loadingProperty(); + BooleanProperty initialLoadComplete = viewModel.initialLoadCompleteProperty(); + + BooleanBinding isOnLastPage = Bindings.createBooleanBinding(() -> { + int currentPage = viewModel.currentPageProperty().get(); + int totalPages = viewModel.totalPagesProperty().get(); + return currentPage >= totalPages - 1; + }, viewModel.currentPageProperty(), viewModel.totalPagesProperty()); + + BooleanBinding isPagedFetcher = Bindings.createBooleanBinding(() -> + searchBasedFetcher.isPresent() && searchBasedFetcher.get() instanceof PagedSearchBasedFetcher + ); + + // Disable: during loading OR when on the last page for non-paged fetchers + // OR when the initial load is not complete for paged fetchers + nextPageButton.disableProperty().bind( + loading.or(isOnLastPage.and(isPagedFetcher.not())) + .or(isPagedFetcher.and(initialLoadComplete.not())) + ); + prevPageButton.disableProperty().bind(loading.or(viewModel.currentPageProperty().isEqualTo(0))); + + prevPageButton.textProperty().bind( + Bindings.when(loading) + .then("< " + Localization.lang("Loading...")) + .otherwise("< " + Localization.lang("Previous")) + ); + + nextPageButton.textProperty().bind( + Bindings.when(loading) + .then(Localization.lang("Loading...") + " >") + .otherwise( + Bindings.when(initialLoadComplete.not().and(isPagedFetcher)) + .then(Localization.lang("Loading initial entries...")) + .otherwise( + Bindings.when(isOnLastPage) + .then( + Bindings.when(isPagedFetcher) + .then(Localization.lang("Load More") + " >>") + .otherwise(Localization.lang("No more entries")) + ) + .otherwise(Localization.lang("Next") + " >") + ) + ) + ); + + statusLabel.textProperty().bind( + Bindings.when(loading) + .then(Localization.lang("Fetching more entries...")) + .otherwise( + Bindings.when(initialLoadComplete.not().and(isPagedFetcher)) + .then(Localization.lang("Loading initial results...")) + .otherwise( + Bindings.when(isOnLastPage) + .then( + Bindings.when(isPagedFetcher) + .then(Localization.lang("Click 'Load More' to fetch additional entries")) + .otherwise(Bindings.createStringBinding(() -> { + int totalEntries = viewModel.getAllEntries().size(); + return totalEntries > 0 ? + Localization.lang("All %0 entries loaded", String.valueOf(totalEntries)) : + Localization.lang("No entries available"); + }, viewModel.getAllEntries())) + ) + .otherwise("") + ) + ) + ); + + loading.addListener((_, _, newVal) -> { + getDialogPane().getScene().setCursor(newVal ? Cursor.WAIT : Cursor.DEFAULT); + }); + + isOnLastPage.addListener((_, oldVal, newVal) -> { + if (newVal && !oldVal) { + statusLabel.getStyleClass().add("info-message"); + } else if (!newVal && oldVal) { + statusLabel.getStyleClass().remove("info-message"); + } + }); +} + + private void updatePageUI() { + pageNumberLabel.textProperty().bind(Bindings.createStringBinding(() -> { + int totalPages = viewModel.totalPagesProperty().get(); + int currentPage = viewModel.currentPageProperty().get() + 1; + if (totalPages != 0) { + return Localization.lang("%0 of %1", currentPage, totalPages); + } + return ""; + }, viewModel.currentPageProperty(), viewModel.totalPagesProperty())); + + viewModel.getAllEntries().addListener((ListChangeListener) change -> { + while (change.next()) { + if (change.wasAdded() || change.wasRemoved()) { + viewModel.updateTotalPages(); + } + } + }); } private void displayBibTeX(BibEntry entry, String bibTeX) { - if (entriesListView.getCheckModel().isChecked(entry)) { + if (viewModel.getCheckedEntries().contains(entry)) { bibTeXData.clear(); bibTeXData.appendText(bibTeX); bibTeXData.moveTo(0); @@ -208,14 +368,16 @@ private void initBibTeX() { } public void unselectAll() { - entriesListView.getCheckModel().clearChecks(); + viewModel.getCheckedEntries().clear(); + entriesListView.getItems().forEach(entry -> entriesListView.getCheckModel().clearCheck(entry)); } public void selectAllNewEntries() { unselectAll(); - for (BibEntry entry : entriesListView.getItems()) { + for (BibEntry entry : viewModel.getAllEntries()) { if (!viewModel.hasDuplicate(entry)) { entriesListView.getCheckModel().check(entry); + viewModel.getCheckedEntries().add(entry); displayBibTeX(entry, viewModel.getSourceString(entry)); } } @@ -224,5 +386,40 @@ public void selectAllNewEntries() { public void selectAllEntries() { unselectAll(); entriesListView.getCheckModel().checkAll(); + viewModel.getCheckedEntries().addAll(viewModel.getAllEntries()); + } + + private boolean isOnLastPageAndPagedFetcher() { + if (searchBasedFetcher.isEmpty() || !(searchBasedFetcher.get() instanceof PagedSearchBasedFetcher)) { + return false; + } + + int currentPage = viewModel.currentPageProperty().get(); + int totalPages = viewModel.totalPagesProperty().get(); + return currentPage >= totalPages - 1; + } + + @FXML + private void onPrevPage() { + viewModel.goToPrevPage(); + restoreCheckedEntries(); + } + + @FXML + private void onNextPage() { + if (isOnLastPageAndPagedFetcher()) { + viewModel.fetchMoreEntries(); + } else { + viewModel.goToNextPage(); + } + restoreCheckedEntries(); + } + + private void restoreCheckedEntries() { + for (BibEntry entry : viewModel.getEntries()) { + if (viewModel.getCheckedEntries().contains(entry)) { + entriesListView.getCheckModel().check(entry); + } + } } } diff --git a/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java b/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java index 72314e53031..c841a79c21b 100644 --- a/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java @@ -2,17 +2,23 @@ import java.io.IOException; import java.io.StringWriter; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import javax.swing.undo.UndoManager; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.collections.FXCollections; import javafx.collections.ObservableList; +import javafx.collections.ObservableSet; import org.jabref.gui.AbstractViewModel; import org.jabref.gui.DialogService; @@ -24,7 +30,9 @@ import org.jabref.logic.database.DatabaseMerger; import org.jabref.logic.database.DuplicateCheck; import org.jabref.logic.exporter.BibWriter; +import org.jabref.logic.importer.PagedSearchBasedFetcher; import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.SearchBasedFetcher; import org.jabref.logic.l10n.Localization; import org.jabref.logic.os.OS; import org.jabref.logic.util.BackgroundTask; @@ -40,6 +48,7 @@ public class ImportEntriesViewModel extends AbstractViewModel { private static final Logger LOGGER = LoggerFactory.getLogger(ImportEntriesViewModel.class); + private static final int PAGE_SIZE = 20; private final StringProperty message; private final TaskExecutor taskExecutor; @@ -54,6 +63,16 @@ public class ImportEntriesViewModel extends AbstractViewModel { private final BibEntryTypesManager entryTypesManager; private final ObjectProperty selectedDb; + private final IntegerProperty currentPageProperty = new SimpleIntegerProperty(0); + private final IntegerProperty totalPagesProperty = new SimpleIntegerProperty(0); + private final BooleanProperty loading = new SimpleBooleanProperty(false); + private final BooleanProperty initialLoadComplete = new SimpleBooleanProperty(false); + private final ObservableList pagedEntries = FXCollections.observableArrayList(); + private final ObservableSet checkedEntries = FXCollections.observableSet(); + private final ObservableList allEntries = FXCollections.observableArrayList(); + private final Optional fetcher; + private final Optional query; + /** * @param databaseContext the database to import into * @param task the task executed for parsing the selected files(s). @@ -66,7 +85,9 @@ public ImportEntriesViewModel(BackgroundTask task, GuiPreferences preferences, StateManager stateManager, BibEntryTypesManager entryTypesManager, - FileUpdateMonitor fileUpdateMonitor) { + FileUpdateMonitor fileUpdateMonitor, + Optional fetcher, + Optional query) { this.taskExecutor = taskExecutor; this.databaseContext = databaseContext; this.dialogService = dialogService; @@ -79,21 +100,32 @@ public ImportEntriesViewModel(BackgroundTask task, this.message = new SimpleStringProperty(); this.message.bind(task.messageProperty()); this.selectedDb = new SimpleObjectProperty<>(); + this.fetcher = fetcher; + this.query = query; task.onSuccess(parserResult -> { // store the complete parser result (to import groups, ... later on) this.parserResult = parserResult; // fill in the list for the user, where one can select the entries to import entries.addAll(parserResult.getDatabase().getEntries()); + loadEntries(entries); + updatePagedEntries(); + updateTotalPages(); + initialLoadComplete.set(true); if (entries.isEmpty()) { task.updateMessage(Localization.lang("No entries corresponding to given query")); } }).onFailure(ex -> { LOGGER.error("Error importing", ex); + initialLoadComplete.set(true); dialogService.showErrorDialogAndWait(ex); }).executeWith(taskExecutor); } + public BooleanProperty initialLoadCompleteProperty() { + return initialLoadComplete; + } + public String getMessage() { return message.get(); } @@ -110,8 +142,24 @@ public BibDatabaseContext getSelectedDb() { return selectedDb.get(); } + public BooleanProperty loadingProperty() { + return loading; + } + public ObservableList getEntries() { - return entries; + return pagedEntries; + } + + public ObservableSet getCheckedEntries() { + return checkedEntries; + } + + public ObservableList getAllEntries() { + return allEntries; + } + + public void loadEntries(List entries) { + allEntries.addAll(entries); } public boolean hasDuplicate(BibEntry entry) { @@ -179,4 +227,88 @@ private Optional findInternalDuplicate(BibEntry entry) { } return Optional.empty(); } + + public void goToPrevPage() { + if (hasPrevPage()) { + currentPageProperty.set(currentPageProperty.get() - 1); + updatePagedEntries(); + } + } + + public void goToNextPage() { + if (hasNextPage()) { + currentPageProperty.set(currentPageProperty.get() + 1); + updatePagedEntries(); + } + } + + public boolean hasNextPage() { + return (currentPageProperty.get() + 1) * PAGE_SIZE < allEntries.size(); + } + + public boolean hasPrevPage() { + return currentPageProperty.get() > 0; + } + + public IntegerProperty currentPageProperty() { + return currentPageProperty; + } + + public IntegerProperty totalPagesProperty() { + return totalPagesProperty; + } + + public void updateTotalPages() { + int total = (int) Math.ceil((double) allEntries.size() / PAGE_SIZE); + totalPagesProperty.set(total); + } + + private boolean isFromWebSearch() { + return fetcher.isPresent() && query.isPresent(); + } + + private void updatePagedEntries() { + if (!isFromWebSearch()) { + // For entries other than web search, show all entries at once + pagedEntries.setAll(allEntries); + return; + } + + int fromIdx = currentPageProperty.get() * PAGE_SIZE; + int toIdx = Math.min(fromIdx + PAGE_SIZE, allEntries.size()); + pagedEntries.setAll(allEntries.subList(fromIdx, toIdx)); + } + + public void fetchMoreEntries() { + if (fetcher.isPresent() && + fetcher.get() instanceof PagedSearchBasedFetcher pagedFetcher && + query.isPresent() && !loading.get()) { + loading.set(true); + BackgroundTask> fetchTask = BackgroundTask + .wrap(() -> { + LOGGER.info("Fetching entries from {} for page {}", fetcher.get().getName(), currentPageProperty.get() + 2); + return new ArrayList<>(pagedFetcher.performSearchPaged(query.get(), currentPageProperty.get() + 1).getContent()); + }) + .onSuccess(newEntries -> { + if (newEntries != null && !newEntries.isEmpty()) { + allEntries.addAll(newEntries); + updateTotalPages(); + } else { + LOGGER.warn("No new entries fetched from {} for page {}", fetcher.get().getName(), currentPageProperty.get() + 2); + dialogService.notify(Localization.lang("No new entries found from %0", fetcher.get().getName())); + } + loading.set(false); + }) + .onFailure(exception -> { + loading.set(false); + dialogService.showErrorDialogAndWait( + Localization.lang("Error fetching entries"), + Localization.lang("An error occurred while fetching entries from %0: %1", + fetcher.get().getName(), exception.getMessage()) + ); + }); + + fetchTask.executeWith(taskExecutor); + } + } } diff --git a/jabgui/src/main/java/org/jabref/gui/importer/fetcher/WebSearchPaneViewModel.java b/jabgui/src/main/java/org/jabref/gui/importer/fetcher/WebSearchPaneViewModel.java index 9057d8078b8..e0406752482 100644 --- a/jabgui/src/main/java/org/jabref/gui/importer/fetcher/WebSearchPaneViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/importer/fetcher/WebSearchPaneViewModel.java @@ -162,7 +162,7 @@ public void search() { .withInitialMessage(Localization.lang("Processing \"%0\"...", query)); task.onFailure(dialogService::showErrorDialogAndWait); - ImportEntriesDialog dialog = new ImportEntriesDialog(stateManager.getActiveDatabase().get(), task); + ImportEntriesDialog dialog = new ImportEntriesDialog(stateManager.getActiveDatabase().get(), task, activeFetcher, query); dialog.setTitle(fetcherName); dialogService.showCustomDialogAndWait(dialog); } diff --git a/jabgui/src/main/resources/org/jabref/gui/importer/ImportEntriesDialog.fxml b/jabgui/src/main/resources/org/jabref/gui/importer/ImportEntriesDialog.fxml index a7061744df7..8f79d64fecd 100644 --- a/jabgui/src/main/resources/org/jabref/gui/importer/ImportEntriesDialog.fxml +++ b/jabgui/src/main/resources/org/jabref/gui/importer/ImportEntriesDialog.fxml @@ -20,6 +20,16 @@