From fd1cab0eb1ed51261049b7799584b060dbe46755 Mon Sep 17 00:00:00 2001 From: Benedikt Tutzer Date: Sat, 21 Aug 2021 20:47:23 +0200 Subject: [PATCH] Implement an interface to import PDF metadata from multiple sources (XMP, Grobid, ...) (#7929) Co-authored-by: Christoph Co-authored-by: Carl Christian Snethlage <50491877+calixtus@users.noreply.github.com> --- CHANGELOG.md | 1 + .../externalfiles/DownloadFullTextAction.java | 3 +- .../gui/fieldeditors/LinkedFileViewModel.java | 87 ++-- .../gui/fieldeditors/LinkedFilesEditor.java | 20 +- .../LinkedFilesEditorViewModel.java | 15 +- .../gui/importer/ImportEntriesViewModel.java | 3 +- .../gui/maintable/OpenExternalFileAction.java | 3 +- .../gui/maintable/OpenFolderAction.java | 3 +- .../gui/maintable/columns/FileColumn.java | 6 +- .../DiffHighlightingEllipsingTextFlow.java | 170 +++++++ .../jabref/gui/mergeentries/MergeEntries.java | 20 +- .../gui/mergeentries/MultiMergeEntries.css | 28 ++ .../gui/mergeentries/MultiMergeEntries.fxml | 55 +++ .../mergeentries/MultiMergeEntriesView.java | 428 ++++++++++++++++++ .../MultiMergeEntriesViewModel.java | 112 +++++ src/main/resources/l10n/JabRef_en.properties | 12 + .../fieldeditors/LinkedFileViewModelTest.java | 27 +- 17 files changed, 924 insertions(+), 69 deletions(-) create mode 100644 src/main/java/org/jabref/gui/mergeentries/DiffHighlightingEllipsingTextFlow.java create mode 100644 src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.css create mode 100644 src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.fxml create mode 100644 src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesView.java create mode 100644 src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesViewModel.java diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e256d75b1..b2e64b5f647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Note that this project **does not** adhere to [Semantic Versioning](http://semve - We added a fulltext search feature. [#2838](https://github.com/JabRef/jabref/pull/2838) - We improved the deduction of bib-entries from imported fulltext pdfs. [#7947](https://github.com/JabRef/jabref/pull/7947) - We added unprotect_terms to the list of bracketed pattern modifiers [#7826](https://github.com/JabRef/jabref/pull/7960) +- We added a dialog that allows to parse metadata from linked pdfs. [#7929](https://github.com/JabRef/jabref/pull/7929) - We added an icon picker in group edit dialog. [#6142](https://github.com/JabRef/jabref/issues/6142) - We added a preference to Opt-In to JabRef's online metadata extraction service (Grobid) usage. [8002](https://github.com/JabRef/jabref/pull/8002) diff --git a/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java b/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java index 7f328a91ba1..93b32af2375 100644 --- a/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java +++ b/src/main/java/org/jabref/gui/externalfiles/DownloadFullTextAction.java @@ -144,8 +144,7 @@ private void addLinkedFileFromURL(BibDatabaseContext databaseContext, URL url, B databaseContext, Globals.TASK_EXECUTOR, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, ExternalFileTypes.getInstance()); onlineFile.download(); diff --git a/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java b/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java index 32b1e7fba3e..28ad0bb3166 100644 --- a/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java +++ b/src/main/java/org/jabref/gui/fieldeditors/LinkedFileViewModel.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Optional; import java.util.function.BiPredicate; +import java.util.function.Supplier; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.HttpsURLConnection; @@ -35,15 +36,22 @@ import org.jabref.gui.icon.IconTheme; import org.jabref.gui.icon.JabRefIcon; import org.jabref.gui.linkedfile.LinkedFileEditDialogView; +import org.jabref.gui.mergeentries.MultiMergeEntriesView; import org.jabref.gui.util.BackgroundTask; import org.jabref.gui.util.ControlHelper; import org.jabref.gui.util.TaskExecutor; import org.jabref.logic.externalfiles.LinkedFileHandler; +import org.jabref.logic.importer.Importer; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.importer.fileformat.PdfContentImporter; +import org.jabref.logic.importer.fileformat.PdfEmbeddedBibFileImporter; +import org.jabref.logic.importer.fileformat.PdfGrobidImporter; +import org.jabref.logic.importer.fileformat.PdfVerbatimBibTextImporter; +import org.jabref.logic.importer.fileformat.PdfXmpImporter; import org.jabref.logic.l10n.Localization; import org.jabref.logic.net.URLDownload; import org.jabref.logic.util.io.FileNameUniqueness; import org.jabref.logic.util.io.FileUtil; -import org.jabref.logic.xmp.XmpPreferences; import org.jabref.logic.xmp.XmpUtilWriter; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; @@ -51,7 +59,7 @@ import org.jabref.model.strings.StringUtil; import org.jabref.model.util.FileHelper; import org.jabref.model.util.OptionalUtil; -import org.jabref.preferences.FilePreferences; +import org.jabref.preferences.PreferencesService; import de.saxsys.mvvmfx.utils.validation.FunctionBasedValidator; import de.saxsys.mvvmfx.utils.validation.ValidationMessage; @@ -69,12 +77,11 @@ public class LinkedFileViewModel extends AbstractViewModel { private final DoubleProperty downloadProgress = new SimpleDoubleProperty(-1); private final BooleanProperty downloadOngoing = new SimpleBooleanProperty(false); private final BooleanProperty isAutomaticallyFound = new SimpleBooleanProperty(false); - private final BooleanProperty canWriteXMPMetadata = new SimpleBooleanProperty(false); + private final BooleanProperty isOfflinePdf = new SimpleBooleanProperty(false); private final DialogService dialogService; private final BibEntry entry; private final TaskExecutor taskExecutor; - private final FilePreferences filePreferences; - private final XmpPreferences xmpPreferences; + private final PreferencesService preferences; private final LinkedFileHandler linkedFileHandler; private final ExternalFileTypes externalFileTypes; @@ -85,19 +92,17 @@ public LinkedFileViewModel(LinkedFile linkedFile, BibDatabaseContext databaseContext, TaskExecutor taskExecutor, DialogService dialogService, - XmpPreferences xmpPreferences, - FilePreferences filePreferences, + PreferencesService preferences, ExternalFileTypes externalFileTypes) { this.linkedFile = linkedFile; - this.filePreferences = filePreferences; - this.linkedFileHandler = new LinkedFileHandler(linkedFile, entry, databaseContext, filePreferences); + this.preferences = preferences; + this.linkedFileHandler = new LinkedFileHandler(linkedFile, entry, databaseContext, preferences.getFilePreferences()); this.databaseContext = databaseContext; this.entry = entry; this.dialogService = dialogService; this.taskExecutor = taskExecutor; this.externalFileTypes = externalFileTypes; - this.xmpPreferences = xmpPreferences; fileExistsValidator = new FunctionBasedValidator<>( linkedFile.linkProperty(), @@ -105,18 +110,18 @@ public LinkedFileViewModel(LinkedFile linkedFile, if (linkedFile.isOnlineLink()) { return true; } else { - Optional path = FileHelper.find(databaseContext, link, filePreferences); + Optional path = FileHelper.find(databaseContext, link, preferences.getFilePreferences()); return path.isPresent() && Files.exists(path.get()); } }, ValidationMessage.warning(Localization.lang("Could not find file '%0'.", linkedFile.getLink()))); downloadOngoing.bind(downloadProgress.greaterThanOrEqualTo(0).and(downloadProgress.lessThan(1))); - canWriteXMPMetadata.setValue(!linkedFile.isOnlineLink() && linkedFile.getFileType().equalsIgnoreCase("pdf")); + isOfflinePdf.setValue(!linkedFile.isOnlineLink() && linkedFile.getFileType().equalsIgnoreCase("pdf")); } - public BooleanProperty canWriteXMPMetadataProperty() { - return canWriteXMPMetadata; + public BooleanProperty isOfflinePdfProperty() { + return isOfflinePdf; } public boolean isAutomaticallyFound() { @@ -211,7 +216,7 @@ public void openFolder() { Optional resolvedPath = FileHelper.find( databaseContext, linkedFile.getLink(), - filePreferences); + preferences.getFilePreferences()); if (resolvedPath.isPresent()) { JabRefDesktop.openFolderAndSelectFile(resolvedPath.get()); @@ -246,7 +251,7 @@ public void renameFileToName(String targetFileName) { return; } - Optional file = linkedFile.findIn(databaseContext, filePreferences); + Optional file = linkedFile.findIn(databaseContext, preferences.getFilePreferences()); if (file.isPresent()) { performRenameWithConflictCheck(targetFileName); } else { @@ -283,13 +288,13 @@ public void moveToDefaultDirectory() { } // Get target folder - Optional fileDir = databaseContext.getFirstExistingFileDir(filePreferences); + Optional fileDir = databaseContext.getFirstExistingFileDir(preferences.getFilePreferences()); if (fileDir.isEmpty()) { dialogService.showErrorDialogAndWait(Localization.lang("Move file"), Localization.lang("File directory is not set or does not exist!")); return; } - Optional file = linkedFile.findIn(databaseContext, filePreferences); + Optional file = linkedFile.findIn(databaseContext, preferences.getFilePreferences()); if ((file.isPresent())) { // Found the linked file, so move it try { @@ -325,9 +330,9 @@ public boolean isGeneratedNameSameAsOriginal() { * @return true if suggested filepath is same as existing filepath. */ public boolean isGeneratedPathSameAsOriginal() { - Optional newDir = databaseContext.getFirstExistingFileDir(filePreferences); + Optional newDir = databaseContext.getFirstExistingFileDir(preferences.getFilePreferences()); - Optional currentDir = linkedFile.findIn(databaseContext, filePreferences).map(Path::getParent); + Optional currentDir = linkedFile.findIn(databaseContext, preferences.getFilePreferences()).map(Path::getParent); BiPredicate equality = (fileA, fileB) -> { try { @@ -351,7 +356,7 @@ public void moveToDefaultDirectoryAndRename() { * successfully, does not exist in the first place or the user choose to remove it) */ public boolean delete() { - Optional file = linkedFile.findIn(databaseContext, filePreferences); + Optional file = linkedFile.findIn(databaseContext, preferences.getFilePreferences()); if (file.isEmpty()) { LOGGER.warn("Could not find file " + linkedFile.getLink()); @@ -395,13 +400,13 @@ public void edit() { public void writeXMPMetadata() { // Localization.lang("Writing XMP metadata...") BackgroundTask writeTask = BackgroundTask.wrap(() -> { - Optional file = linkedFile.findIn(databaseContext, filePreferences); + Optional file = linkedFile.findIn(databaseContext, preferences.getFilePreferences()); if (file.isEmpty()) { // TODO: Print error message // Localization.lang("PDF does not exist"); } else { try { - XmpUtilWriter.writeXmp(file.get(), entry, databaseContext.getDatabase(), xmpPreferences); + XmpUtilWriter.writeXmp(file.get(), entry, databaseContext.getDatabase(), preferences.getXmpPreferences()); } catch (IOException | TransformerException ex) { // TODO: Print error message // Localization.lang("Error while writing") + " '" + file.toString() + "': " + ex; @@ -421,7 +426,7 @@ public void download() { throw new UnsupportedOperationException("In order to download the file it has to be an online link"); } try { - Optional targetDirectory = databaseContext.getFirstExistingFileDir(filePreferences); + Optional targetDirectory = databaseContext.getFirstExistingFileDir(preferences.getFilePreferences()); if (targetDirectory.isEmpty()) { dialogService.showErrorDialogAndWait(Localization.lang("Download file"), Localization.lang("File directory is not set or does not exist!")); return; @@ -443,7 +448,7 @@ public void download() { } if (!isDuplicate) { - LinkedFile newLinkedFile = LinkedFilesEditorViewModel.fromFile(destination, databaseContext.getFileDirectories(filePreferences), externalFileTypes); + LinkedFile newLinkedFile = LinkedFilesEditorViewModel.fromFile(destination, databaseContext.getFileDirectories(preferences.getFilePreferences()), externalFileTypes); List linkedFiles = entry.getFiles(); entry.addLinkedFile(entry, linkedFile, newLinkedFile, linkedFiles); @@ -495,7 +500,7 @@ public BackgroundTask prepareDownloadTask(Path targetDirectory, URLDownloa String suggestedTypeName = externalFileType.getName(); linkedFile.setFileType(suggestedTypeName); String suggestedName = linkedFileHandler.getSuggestedFileName(externalFileType.getExtension()); - String fulltextDir = FileUtil.createDirNameFromPattern(databaseContext.getDatabase(), entry, filePreferences.getFileDirectoryPattern()); + String fulltextDir = FileUtil.createDirNameFromPattern(databaseContext.getDatabase(), entry, preferences.getFilePreferences().getFileDirectoryPattern()); suggestedName = FileNameUniqueness.getNonOverWritingFileName(targetDirectory.resolve(fulltextDir), suggestedName); return targetDirectory.resolve(fulltextDir).resolve(suggestedName); }) @@ -538,4 +543,34 @@ public LinkedFile getFile() { public ValidationStatus fileExistsValidationStatus() { return fileExistsValidator.getValidationStatus(); } + + public void parsePdfMetadataAndShowMergeDialog() { + linkedFile.findIn(databaseContext, preferences.getFilePreferences()).ifPresent(filePath -> { + MultiMergeEntriesView dialog = new MultiMergeEntriesView(preferences, taskExecutor); + dialog.addSource(Localization.lang("Entry"), entry); + dialog.addSource(Localization.lang("Verbatim"), wrapImporterToSupplier(new PdfVerbatimBibTextImporter(preferences.getImportFormatPreferences()), filePath)); + dialog.addSource(Localization.lang("Embedded"), wrapImporterToSupplier(new PdfEmbeddedBibFileImporter(preferences.getImportFormatPreferences()), filePath)); + dialog.addSource("Grobid", wrapImporterToSupplier(new PdfGrobidImporter(preferences.getImportSettingsPreferences(), preferences.getImportFormatPreferences()), filePath)); + dialog.addSource(Localization.lang("XMP metadata"), wrapImporterToSupplier(new PdfXmpImporter(preferences.getXmpPreferences()), filePath)); + dialog.addSource(Localization.lang("Content"), wrapImporterToSupplier(new PdfContentImporter(preferences.getImportFormatPreferences()), filePath)); + dialog.showAndWait().ifPresent(newEntry -> { + databaseContext.getDatabase().removeEntry(entry); + databaseContext.getDatabase().insertEntry(newEntry); + }); + }); + } + + private Supplier wrapImporterToSupplier(Importer importer, Path filePath) { + return () -> { + try { + ParserResult parserResult = importer.importDatabase(filePath, preferences.getDefaultEncoding()); + if (parserResult.isInvalid() || parserResult.isEmpty() || !parserResult.getDatabase().hasEntries()) { + return null; + } + return parserResult.getDatabase().getEntries().get(0); + } catch (IOException e) { + return null; + } + }; + } } diff --git a/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java b/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java index 89859d5251f..6809b5d058f 100644 --- a/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java +++ b/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditor.java @@ -23,6 +23,7 @@ import javafx.scene.input.MouseEvent; import javafx.scene.input.TransferMode; import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; import javafx.scene.text.Text; import org.jabref.gui.DialogService; @@ -34,6 +35,7 @@ import org.jabref.gui.autocompleter.SuggestionProvider; import org.jabref.gui.copyfiles.CopySingleFileAction; import org.jabref.gui.icon.IconTheme; +import org.jabref.gui.importer.GrobidOptInDialogHelper; import org.jabref.gui.keyboard.KeyBinding; import org.jabref.gui.util.BindingsHelper; import org.jabref.gui.util.TaskExecutor; @@ -81,7 +83,7 @@ public LinkedFilesEditor(Field field, ViewModelListCellFactory cellFactory = new ViewModelListCellFactory() .withStringTooltip(LinkedFileViewModel::getDescription) - .withGraphic(LinkedFilesEditor::createFileDisplay) + .withGraphic(this::createFileDisplay) .withContextMenu(this::createContextMenuForFile) .withOnMouseClickedEvent(this::handleItemMouseClick) .setOnDragDetected(this::handleOnDragDetected) @@ -142,7 +144,7 @@ private void handleOnDragDropped(LinkedFileViewModel originalItem, DragEvent eve event.consume(); } - private static Node createFileDisplay(LinkedFileViewModel linkedFile) { + private Node createFileDisplay(LinkedFileViewModel linkedFile) { PseudoClass opacity = PseudoClass.getPseudoClass("opacity"); Node icon = linkedFile.getTypeIcon().getGraphicNode(); @@ -162,6 +164,7 @@ private static Node createFileDisplay(LinkedFileViewModel linkedFile) { progressIndicator.visibleProperty().bind(linkedFile.downloadOngoingProperty()); HBox info = new HBox(8); + HBox.setHgrow(info, Priority.ALWAYS); info.setStyle("-fx-padding: 0.5em 0 0.5em 0;"); // To align with buttons below which also have 0.5em padding info.getChildren().setAll(icon, link, desc, progressIndicator); @@ -174,14 +177,23 @@ private static Node createFileDisplay(LinkedFileViewModel linkedFile) { Button writeXMPMetadata = IconTheme.JabRefIcons.IMPORT.asButton(); writeXMPMetadata.setTooltip(new Tooltip(Localization.lang("Write BibTeXEntry as XMP metadata to PDF."))); - writeXMPMetadata.visibleProperty().bind(linkedFile.canWriteXMPMetadataProperty()); + writeXMPMetadata.visibleProperty().bind(linkedFile.isOfflinePdfProperty()); writeXMPMetadata.setOnAction(event -> linkedFile.writeXMPMetadata()); writeXMPMetadata.getStyleClass().setAll("icon-button"); + Button parsePdfMetadata = IconTheme.JabRefIcons.FILE_SEARCH.asButton(); + parsePdfMetadata.setTooltip(new Tooltip(Localization.lang("Parse Metadata from PDF."))); + parsePdfMetadata.visibleProperty().bind(linkedFile.isOfflinePdfProperty()); + parsePdfMetadata.setOnAction(event -> { + GrobidOptInDialogHelper.showAndWaitIfUserIsUndecided(dialogService); + linkedFile.parsePdfMetadataAndShowMergeDialog(); + }); + parsePdfMetadata.getStyleClass().setAll("icon-button"); + HBox container = new HBox(10); container.setPrefHeight(Double.NEGATIVE_INFINITY); - container.getChildren().addAll(acceptAutoLinkedFile, info, writeXMPMetadata); + container.getChildren().addAll(acceptAutoLinkedFile, info, writeXMPMetadata, parsePdfMetadata); return container; } diff --git a/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditorViewModel.java b/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditorViewModel.java index de4ccfa1e84..e3f542e4a95 100644 --- a/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditorViewModel.java +++ b/src/main/java/org/jabref/gui/fieldeditors/LinkedFilesEditorViewModel.java @@ -106,8 +106,7 @@ public LinkedFileViewModel fromFile(Path file) { databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, externalFileTypes); } @@ -123,8 +122,7 @@ private List parseToFileViewModel(String stringValue) { databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, externalFileTypes)) .collect(Collectors.toList()); } @@ -154,8 +152,7 @@ public void addNewFile() { databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, externalFileTypes)); }); } @@ -192,8 +189,7 @@ private List findAssociatedNotLinkedFiles(BibEntry entry) { databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, externalFileTypes); newLinkedFile.markAsAutomaticallyFound(); result.add(newLinkedFile); @@ -243,8 +239,7 @@ private void addFromURL(URL url) { databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - preferences.getFilePreferences(), + preferences, externalFileTypes); files.add(onlineFile); onlineFile.download(); diff --git a/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java b/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java index 5402e77a4e4..a5dfacdfec5 100644 --- a/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java +++ b/src/main/java/org/jabref/gui/importer/ImportEntriesViewModel.java @@ -152,8 +152,7 @@ public void importEntries(List entriesToImport, boolean shouldDownload databaseContext, taskExecutor, dialogService, - preferences.getXmpPreferences(), - filePreferences, + preferences, ExternalFileTypes.getInstance()); linkedFileViewModel.download(); } diff --git a/src/main/java/org/jabref/gui/maintable/OpenExternalFileAction.java b/src/main/java/org/jabref/gui/maintable/OpenExternalFileAction.java index a11ab0dfa88..1c5b182689d 100644 --- a/src/main/java/org/jabref/gui/maintable/OpenExternalFileAction.java +++ b/src/main/java/org/jabref/gui/maintable/OpenExternalFileAction.java @@ -54,8 +54,7 @@ public void execute() { databaseContext, Globals.TASK_EXECUTOR, dialogService, - preferencesService.getXmpPreferences(), - preferencesService.getFilePreferences(), + preferencesService, ExternalFileTypes.getInstance()); linkedFileViewModelList.add(linkedFileViewModel); diff --git a/src/main/java/org/jabref/gui/maintable/OpenFolderAction.java b/src/main/java/org/jabref/gui/maintable/OpenFolderAction.java index 8b147729d86..ae7540d110b 100644 --- a/src/main/java/org/jabref/gui/maintable/OpenFolderAction.java +++ b/src/main/java/org/jabref/gui/maintable/OpenFolderAction.java @@ -37,8 +37,7 @@ public void execute() { databaseContext, Globals.TASK_EXECUTOR, dialogService, - preferencesService.getXmpPreferences(), - preferencesService.getFilePreferences(), + preferencesService, ExternalFileTypes.getInstance() ); linkedFileViewModel.openFolder(); diff --git a/src/main/java/org/jabref/gui/maintable/columns/FileColumn.java b/src/main/java/org/jabref/gui/maintable/columns/FileColumn.java index 3ad18d627ab..c963ed87b23 100644 --- a/src/main/java/org/jabref/gui/maintable/columns/FileColumn.java +++ b/src/main/java/org/jabref/gui/maintable/columns/FileColumn.java @@ -68,8 +68,7 @@ public FileColumn(MainTableColumnModel model, entry.getEntry(), database, Globals.TASK_EXECUTOR, dialogService, - preferencesService.getXmpPreferences(), - preferencesService.getFilePreferences(), + preferencesService, externalFileTypes); linkedFileViewModel.open(); } @@ -132,8 +131,7 @@ private ContextMenu createFileMenu(BibEntryTableViewModel entry, List allChildren = FXCollections.observableArrayList(); + private ChangeListener sizeChangeListener = (observableValue, number, t1) -> adjustText(); + private ListChangeListener listChangeListener = this::adjustChildren; + + private final String fullText; + private final EasyObservableValue comparisonString; + private final ObjectProperty diffMode; + + public DiffHighlightingEllipsingTextFlow(String fullText, EasyObservableValue comparisonString, ObjectProperty diffMode) { + this.fullText = fullText; + allChildren.addListener(listChangeListener); + widthProperty().addListener(sizeChangeListener); + heightProperty().addListener(sizeChangeListener); + + this.comparisonString = comparisonString; + this.diffMode = diffMode; + comparisonString.addListener((obs, oldValue, newValue) -> highlightDiff()); + diffMode.addListener((obs, oldValue, newValue) -> highlightDiff()); + highlightDiff(); + } + + @Override + public ObservableList getChildren() { + return allChildren; + } + + private void adjustChildren(ListChangeListener.Change change) { + super.getChildren().clear(); + super.getChildren().addAll(allChildren); + super.autosize(); + adjustText(); + } + + private void adjustText() { + if (allChildren.size() == 0) { + return; + } + // remove listeners + widthProperty().removeListener(sizeChangeListener); + heightProperty().removeListener(sizeChangeListener); + + if (removeUntilTextFits() && fillUntilOverflowing()) { + ellipseUntilTextFits(); + } + + widthProperty().addListener(sizeChangeListener); + heightProperty().addListener(sizeChangeListener); + } + + private boolean removeUntilTextFits() { + while (getHeight() > getMaxHeight() || getWidth() > getMaxWidth()) { + if (super.getChildren().isEmpty()) { + // nothing fits + return false; + } + super.getChildren().remove(super.getChildren().size() - 1); + super.autosize(); + } + return true; + } + + private boolean fillUntilOverflowing() { + while (getHeight() <= getMaxHeight() && getWidth() <= getMaxWidth()) { + if (super.getChildren().size() == allChildren.size()) { + if (allChildren.size() > 0) { + // all Texts are displayed, let's make sure all chars are as well + Node lastChildAsShown = super.getChildren().get(super.getChildren().size() - 1); + Node lastChild = allChildren.get(allChildren.size() - 1); + if (lastChildAsShown instanceof Text && ((Text) lastChildAsShown).getText().length() < ((Text) lastChild).getText().length()) { + ((Text) lastChildAsShown).setText(((Text) lastChild).getText()); + } else { + // nothing to fill the space with + return false; + } + } + } else { + super.getChildren().add(allChildren.get(super.getChildren().size())); + } + super.autosize(); + } + return true; + } + + private boolean ellipseUntilTextFits() { + while (getHeight() > getMaxHeight() || getWidth() > getMaxWidth()) { + Text lastChildAsShown = (Text) super.getChildren().remove(super.getChildren().size() - 1); + while (getEllipsisString().equals(lastChildAsShown.getText()) || "".equals(lastChildAsShown.getText())) { + if (super.getChildren().isEmpty()) { + return false; + } + lastChildAsShown = (Text) super.getChildren().remove(super.getChildren().size() - 1); + } + Text shortenedChild = new Text(ellipseString(lastChildAsShown.getText())); + shortenedChild.getStyleClass().addAll(lastChildAsShown.getStyleClass()); + super.getChildren().add(shortenedChild); + super.autosize(); + } + return true; + } + + public void highlightDiff() { + allChildren.clear(); + if (comparisonString.get() != null && !comparisonString.get().equals(fullText)) { + final List highlightedText = switch (diffMode.getValue()) { + case PLAIN -> { + Text text = new Text(fullText); + text.getStyleClass().add("text-unchanged"); + yield List.of(text); + } + case WORD -> DiffHighlighting.generateDiffHighlighting(comparisonString.get(), fullText, " "); + case CHARACTER -> DiffHighlighting.generateDiffHighlighting(comparisonString.get(), fullText, ""); + default -> throw new UnsupportedOperationException("Not implemented " + diffMode.getValue()); + }; + allChildren.addAll(highlightedText); + } else { + Text text = new Text(fullText); + text.getStyleClass().add("text-unchanged"); + allChildren.add(text); + } + super.autosize(); + adjustText(); + } + + private String ellipseString(String s) { + int spacePos = s.lastIndexOf(' '); + if (spacePos <= 0) { + return ""; + } + return s.substring(0, spacePos) + getEllipsisString(); + } + + public final void setEllipsisString(String value) { + ellipsisString.set((value == null) ? "" : value); + } + + public String getEllipsisString() { + return ellipsisString == null ? DEFAULT_ELLIPSIS_STRING : ellipsisString.get(); + } + + public final StringProperty ellipsisStringProperty() { + return ellipsisString; + } + + public String getFullText() { + return fullText; + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java index 42ff20930f7..63ee5c762e5 100644 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java +++ b/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java @@ -425,11 +425,17 @@ public void setRightHeaderText(String rightHeaderText) { public enum DiffMode { - PLAIN, - WORD, - CHARACTER, - WORD_SYMMETRIC, - CHARACTER_SYMMETRIC; + PLAIN(Localization.lang("None")), + WORD(Localization.lang("Word by word")), + CHARACTER(Localization.lang("Character by character")), + WORD_SYMMETRIC(Localization.lang("Symmetric word by word")), + CHARACTER_SYMMETRIC(Localization.lang("Symmetric character by character")); + + private final String text; + + DiffMode(String text) { + this.text = text; + } public static Optional parse(String name) { try { @@ -438,6 +444,10 @@ public static Optional parse(String name) { return Optional.empty(); } } + + public String getDisplayText() { + return text; + } } public enum DefaultRadioButtonSelectionMode { diff --git a/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.css b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.css new file mode 100644 index 00000000000..99dffba7da4 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.css @@ -0,0 +1,28 @@ +.text-changed { + -fx-fill: -jr-orange; +} + +.text-added { + -fx-fill: -jr-green; +} + +.text-removed { + -fx-fill: -jr-red; +} + +.grid-pane { + -fx-hgap: 10; + -fx-vgap: 10; +} + +.toggle-button { + -fx-border-insets: 0; + -fx-background-insets: 0; + -fx-border-image-insets: 0; + -fx-padding: 0; + -fx-background-radius: 0; +} + +.box { + -fx-spacing: 10; +} diff --git a/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.fxml b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.fxml new file mode 100644 index 00000000000..a90588d7df9 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntries.fxml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesView.java b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesView.java new file mode 100644 index 00000000000..cf6d7915f1c --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesView.java @@ -0,0 +1,428 @@ +package org.jabref.gui.mergeentries; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.MapChangeListener; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Control; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputControl; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.RowConstraints; +import javafx.scene.layout.VBox; +import javafx.scene.text.TextAlignment; + +import org.jabref.gui.icon.IconTheme; +import org.jabref.gui.util.BaseDialog; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.DefaultTaskExecutor; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.gui.util.ViewModelListCellFactory; +import org.jabref.logic.importer.FetcherException; +import org.jabref.logic.importer.fetcher.DoiFetcher; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.FieldFactory; +import org.jabref.model.entry.field.StandardField; +import org.jabref.preferences.PreferencesService; + +import com.airhacks.afterburner.views.ViewLoader; +import com.tobiasdiez.easybind.EasyBind; +import com.tobiasdiez.easybind.EasyObservableValue; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class MultiMergeEntriesView extends BaseDialog { + + private static final Logger LOGGER = LoggerFactory.getLogger(MultiMergeEntriesView.class); + + // LEFT + @FXML private ScrollPane leftScrollPane; + @FXML private VBox fieldHeader; + + // CENTER + @FXML private ScrollPane topScrollPane; + @FXML private HBox supplierHeader; + @FXML private ScrollPane centerScrollPane; + @FXML private GridPane optionsGrid; + + // RIGHT + @FXML private ScrollPane rightScrollPane; + @FXML private VBox fieldEditor; + + @FXML private Label failedSuppliers; + @FXML private ComboBox diffMode; + + private final ToggleGroup headerToggleGroup = new ToggleGroup(); + private final HashMap fieldRows = new HashMap<>(); + + private final MultiMergeEntriesViewModel viewModel; + private final TaskExecutor taskExecutor; + + private final PreferencesService preferences; + + public MultiMergeEntriesView(PreferencesService preferences, + TaskExecutor taskExecutor) { + this.preferences = preferences; + this.taskExecutor = taskExecutor; + + viewModel = new MultiMergeEntriesViewModel(); + + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + + ButtonType mergeEntries = new ButtonType(Localization.lang("Merge entries"), ButtonBar.ButtonData.OK_DONE); + this.getDialogPane().getButtonTypes().setAll(ButtonType.CANCEL, mergeEntries); + this.setResultConverter(viewModel::resultConverter); + + viewModel.entriesProperty().addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasAdded()) { + for (MultiMergeEntriesViewModel.EntrySource entrySourceColumn : c.getAddedSubList()) { + addColumn(entrySourceColumn); + } + } + } + }); + + viewModel.mergedEntryProperty().get().getFieldsObservable().addListener((MapChangeListener) change -> { + if (change.wasAdded() && !fieldRows.containsKey(change.getKey())) { + FieldRow fieldRow = new FieldRow( + change.getKey(), + viewModel.mergedEntryProperty().get().getFields().size() - 1); + fieldRows.put(change.getKey(), fieldRow); + } + }); + } + + @FXML + public void initialize() { + topScrollPane.hvalueProperty().bindBidirectional(centerScrollPane.hvalueProperty()); + leftScrollPane.vvalueProperty().bindBidirectional(centerScrollPane.vvalueProperty()); + rightScrollPane.vvalueProperty().bindBidirectional(centerScrollPane.vvalueProperty()); + + viewModel.failedSuppliersProperty().addListener((obs, oldValue, newValue) -> { + failedSuppliers.setText(viewModel.failedSuppliersProperty().get().isEmpty() ? "" : Localization.lang("Could not extract Metadata from: %0", viewModel.failedSuppliersProperty().stream().collect(Collectors.joining(", ")))); + }); + + fillDiffModes(); + } + + private void fillDiffModes() { + diffMode.setItems(FXCollections.observableList(List.of(MergeEntries.DiffMode.PLAIN, MergeEntries.DiffMode.WORD, MergeEntries.DiffMode.CHARACTER))); + new ViewModelListCellFactory() + .withText(MergeEntries.DiffMode::getDisplayText) + .install(diffMode); + MergeEntries.DiffMode diffModePref = preferences.getMergeDiffMode() + .flatMap(MergeEntries.DiffMode::parse) + .orElse(MergeEntries.DiffMode.WORD); + diffMode.setValue(diffModePref); + + EasyBind.subscribe(this.diffMode.valueProperty(), mode -> { + preferences.storeMergeDiffMode(mode.name()); + }); + } + + private void addColumn(MultiMergeEntriesViewModel.EntrySource entrySourceColumn) { + // add header + int columnIndex = supplierHeader.getChildren().size(); + ToggleButton header = generateEntryHeader(entrySourceColumn, columnIndex); + header.getStyleClass().add("toggle-button"); + HBox.setHgrow(header, Priority.ALWAYS); + supplierHeader.getChildren().add(header); + header.setMinWidth(250); + + // setup column constraints + ColumnConstraints constraint = new ColumnConstraints(); + constraint.setMinWidth(Control.USE_PREF_SIZE); + constraint.setMaxWidth(Control.USE_PREF_SIZE); + constraint.prefWidthProperty().bind(header.widthProperty()); + optionsGrid.getColumnConstraints().add(constraint); + + if (!entrySourceColumn.isLoadingProperty().getValue()) { + writeBibEntryToColumn(entrySourceColumn, columnIndex); + } else { + header.setDisable(true); + entrySourceColumn.isLoadingProperty().addListener((observable, oldValue, newValue) -> { + if (!newValue && entrySourceColumn.entryProperty().get() != null) { + writeBibEntryToColumn(entrySourceColumn, columnIndex); + header.setDisable(false); + } + }); + } + } + + private ToggleButton generateEntryHeader(MultiMergeEntriesViewModel.EntrySource column, int columnIndex) { + ToggleButton header = new ToggleButton(); + header.setToggleGroup(headerToggleGroup); + header.textProperty().bind(column.titleProperty()); + setupSourceButtonAction(header, columnIndex); + + if (column.isLoadingProperty().getValue()) { + ProgressIndicator progressIndicator = new ProgressIndicator(-1); + progressIndicator.setPrefHeight(20); + progressIndicator.setMinHeight(Control.USE_PREF_SIZE); + progressIndicator.setMaxHeight(Control.USE_PREF_SIZE); + header.setGraphic(progressIndicator); + progressIndicator.visibleProperty().bind(column.isLoadingProperty()); + } + + column.isLoadingProperty().addListener((obs, oldValue, newValue) -> { + if (!newValue) { + header.setGraphic(null); + if (column.entryProperty().get() == null) { + header.setMinWidth(0); + header.setMaxWidth(0); + header.setVisible(false); + } + } + }); + + return header; + } + + /** + * Adds ToggleButtons for all fields that are set for this BibEntry + * + * @param entrySourceColumn the entry to write + * @param columnIndex the index of the column to write this entry to + */ + private void writeBibEntryToColumn(MultiMergeEntriesViewModel.EntrySource entrySourceColumn, int columnIndex) { + for (Map.Entry entry : entrySourceColumn.entryProperty().get().getFieldsObservable().entrySet()) { + Field key = entry.getKey(); + String value = entry.getValue(); + Cell cell = new Cell(value, key, columnIndex); + optionsGrid.add(cell, columnIndex, fieldRows.get(key).rowIndex); + } + } + + /** + * Set up the button that displays the name of the source so that if it is clicked, all toggles in that column are + * selected. + * + * @param sourceButton the header button to setup + * @param column the column this button is heading + */ + private void setupSourceButtonAction(ToggleButton sourceButton, int column) { + sourceButton.selectedProperty().addListener((observable, oldValue, newValue) -> { + if (newValue) { + optionsGrid.getChildrenUnmodifiable().stream() + .filter(node -> GridPane.getColumnIndex(node) == column) + .filter(node -> node instanceof HBox) + .forEach(hbox -> ((HBox) hbox).getChildrenUnmodifiable().stream() + .filter(node -> node instanceof ToggleButton) + .forEach(toggleButton -> ((ToggleButton) toggleButton).setSelected(true))); + sourceButton.setSelected(true); + } + }); + } + + /** + * Checks if the Field can be multiline + * + * @param field the field to be checked + * @return true if the field may be multiline, false otherwise + */ + private boolean isMultilineField(Field field) { + if (field.equals(StandardField.DOI)) { + return false; + } + return FieldFactory.isMultiLineField(field, preferences.getFieldContentParserPreferences().getNonWrappableFields()); + } + + private class Cell extends HBox { + + private final String content; + + public Cell(String content, Field field, int columnIndex) { + this.content = content; + + /* + If this is not explicitly done on the JavaFX thread, the bindings to the text fields don't work properly. + The text only shows up after one text in that same row is selected by the user. + */ + DefaultTaskExecutor.runInJavaFXThread(() -> { + + FieldRow row = fieldRows.get(field); + + prefWidthProperty().bind(((Region) supplierHeader.getChildren().get(columnIndex)).widthProperty()); + setMinWidth(Control.USE_PREF_SIZE); + setMaxWidth(Control.USE_PREF_SIZE); + prefHeightProperty().bind(((Region) fieldEditor.getChildren().get(row.rowIndex)).heightProperty()); + setMinHeight(Control.USE_PREF_SIZE); + setMaxHeight(Control.USE_PREF_SIZE); + + // Button + ToggleButton cellButton = new ToggleButton(); + cellButton.prefHeightProperty().bind(heightProperty()); + cellButton.setMinHeight(Control.USE_PREF_SIZE); + cellButton.setMaxHeight(Control.USE_PREF_SIZE); + cellButton.setGraphicTextGap(0); + getChildren().add(cellButton); + cellButton.maxWidthProperty().bind(widthProperty()); + HBox.setHgrow(cellButton, Priority.ALWAYS); + + // Text + DiffHighlightingEllipsingTextFlow buttonText = new DiffHighlightingEllipsingTextFlow(content, viewModel.mergedEntryProperty().get().getFieldBinding(field).asOrdinary(), diffMode.valueProperty()); + + buttonText.maxWidthProperty().bind(widthProperty().add(-10)); + buttonText.maxHeightProperty().bind(heightProperty()); + cellButton.setGraphic(buttonText); + cellButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + cellButton.setContentDisplay(ContentDisplay.CENTER); + + // Tooltip + Tooltip buttonTooltip = new Tooltip(content); + buttonTooltip.setWrapText(true); + buttonTooltip.prefWidthProperty().bind(widthProperty()); + buttonTooltip.setTextAlignment(TextAlignment.LEFT); + cellButton.setTooltip(buttonTooltip); + + cellButton.setToggleGroup(row.toggleGroup); + if (row.toggleGroup.getSelectedToggle() == null) { + cellButton.setSelected(true); + } + + if (field.equals(StandardField.DOI)) { + Button doiButton = IconTheme.JabRefIcons.LOOKUP_IDENTIFIER.asButton(); + HBox.setHgrow(doiButton, Priority.NEVER); + doiButton.prefHeightProperty().bind(cellButton.heightProperty()); + doiButton.setMinHeight(Control.USE_PREF_SIZE); + doiButton.setMaxHeight(Control.USE_PREF_SIZE); + + getChildren().add(doiButton); + + doiButton.setOnAction(event -> { + DoiFetcher doiFetcher = new DoiFetcher(preferences.getImportFormatPreferences()); + doiButton.setDisable(true); + addSource(Localization.lang("From DOI"), () -> { + try { + return doiFetcher.performSearchById(content).get(); + } catch (FetcherException | NoSuchElementException e) { + LOGGER.warn("Failed to fetch BibEntry for DOI {}", content, e); + return null; + } + }); + }); + } + }); + } + + public String getContent() { + return content; + } + } + + public void addSource(String title, BibEntry entry) { + viewModel.addSource(new MultiMergeEntriesViewModel.EntrySource(title, entry)); + } + + public void addSource(String title, Supplier supplier) { + viewModel.addSource(new MultiMergeEntriesViewModel.EntrySource(title, supplier, taskExecutor)); + } + + private class FieldRow { + + public final ToggleGroup toggleGroup = new ToggleGroup(); + private final TextInputControl fieldEditorCell; + + private final int rowIndex; + + // Reference needs to be kept, since java garbage collection would otherwise destroy the subscription + @SuppressWarnings("FieldCanBeLocal") private EasyObservableValue fieldBinding; + + public FieldRow(Field field, int rowIndex) { + this.rowIndex = rowIndex; + + // setup field editor column entry + boolean isMultiLine = isMultilineField(field); + if (isMultiLine) { + fieldEditorCell = new TextArea(); + ((TextArea) fieldEditorCell).setWrapText(true); + } else { + fieldEditorCell = new TextField(); + } + + addRow(field); + + fieldEditorCell.addEventFilter(KeyEvent.KEY_PRESSED, event -> toggleGroup.selectToggle(null)); + + toggleGroup.selectedToggleProperty().addListener((obs, oldValue, newValue) -> { + if (newValue == null) { + viewModel.mergedEntryProperty().get().setField(field, ""); + } else { + viewModel.mergedEntryProperty().get().setField(field, ((DiffHighlightingEllipsingTextFlow) ((ToggleButton) newValue).getGraphic()).getFullText()); + headerToggleGroup.selectToggle(null); + } + }); + } + + /** + * Adds a row that represents this field + * + * @param field the field to add to the view as a new row in the table + */ + private void addRow(Field field) { + VBox.setVgrow(fieldEditorCell, Priority.ALWAYS); + + fieldBinding = viewModel.mergedEntryProperty().get().getFieldBinding(field).asOrdinary(); + BindingsHelper.bindBidirectional( + fieldEditorCell.textProperty(), + fieldBinding, + text -> { + if (text != null) { + fieldEditorCell.setText(text); + } + }, + binding -> { + if (binding != null) { + viewModel.mergedEntryProperty().get().setField(field, binding); + } + }); + + fieldEditorCell.setMaxHeight(Double.MAX_VALUE); + VBox.setVgrow(fieldEditorCell, Priority.ALWAYS); + fieldEditor.getChildren().add(fieldEditorCell); + + // setup header label + Label fieldHeaderLabel = new Label(field.getDisplayName()); + fieldHeaderLabel.prefHeightProperty().bind(fieldEditorCell.heightProperty()); + fieldHeaderLabel.setMaxWidth(Control.USE_PREF_SIZE); + fieldHeaderLabel.setMinWidth(Control.USE_PREF_SIZE); + fieldHeader.getChildren().add(fieldHeaderLabel); + + // setup RowConstraints + RowConstraints constraint = new RowConstraints(); + constraint.setMinHeight(Control.USE_PREF_SIZE); + constraint.setMaxHeight(Control.USE_PREF_SIZE); + constraint.prefHeightProperty().bind(fieldEditorCell.heightProperty()); + optionsGrid.getRowConstraints().add(constraint); + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesViewModel.java b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesViewModel.java new file mode 100644 index 00000000000..a73301a7fa4 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MultiMergeEntriesViewModel.java @@ -0,0 +1,112 @@ +package org.jabref.gui.mergeentries; + +import java.util.Map; +import java.util.function.Supplier; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.scene.control.ButtonType; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +public class MultiMergeEntriesViewModel extends AbstractViewModel { + + private final ListProperty entries = new SimpleListProperty<>(FXCollections.observableArrayList()); + + private final ObjectProperty mergedEntry = new SimpleObjectProperty<>(new BibEntry()); + + private final ListProperty failedSuppliers = new SimpleListProperty<>(FXCollections.observableArrayList()); + + public void addSource(EntrySource entrySource) { + if (!entrySource.isLoading.getValue()) { + updateFields(entrySource.entry.get()); + } else { + entrySource.isLoading.addListener((observable, oldValue, newValue) -> { + if (!newValue) { + updateFields(entrySource.entry.get()); + if (entrySource.entryProperty().get() == null) { + failedSuppliers.add(entrySource.titleProperty().get()); + } + } + }); + } + entries.add(entrySource); + } + + public void updateFields(BibEntry entry) { + if (entry == null) { + return; + } + for (Map.Entry fieldEntry : entry.getFieldMap().entrySet()) { + // make sure there is a row for the field + if (!mergedEntry.get().getFieldsObservable().containsKey(fieldEntry.getKey())) { + mergedEntry.get().setField(fieldEntry.getKey(), fieldEntry.getValue()); + } + } + } + + public BibEntry resultConverter(ButtonType button) { + if (button == ButtonType.CANCEL) { + return null; + } + return mergedEntry.get(); + } + + public ListProperty entriesProperty() { + return entries; + } + + public ObjectProperty mergedEntryProperty() { + return mergedEntry; + } + + public ListProperty failedSuppliersProperty() { + return failedSuppliers; + } + + public static class EntrySource { + private final StringProperty title = new SimpleStringProperty(""); + private final ObjectProperty entry = new SimpleObjectProperty<>(); + private final BooleanProperty isLoading = new SimpleBooleanProperty(false); + + public EntrySource(String title, Supplier entrySupplier, TaskExecutor taskExecutor) { + this.title.set(title); + isLoading.set(true); + + BackgroundTask.wrap(entrySupplier::get) + .onSuccess(value -> { + entry.set(value); + isLoading.set(false); + }) + .executeWith(taskExecutor); + } + + public EntrySource(String title, BibEntry entry) { + this.title.set(title); + this.entry.set(entry); + } + + public StringProperty titleProperty() { + return title; + } + + public ObjectProperty entryProperty() { + return entry; + } + + public BooleanProperty isLoadingProperty() { + return isLoading; + } + } +} diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index 0340257fc4c..fe3a4b480a2 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -1173,6 +1173,7 @@ Please\ open\ or\ start\ a\ new\ library\ before\ searching=Please open or start Canceled\ merging\ entries=Canceled merging entries Merge\ entries=Merge entries +Merged\ entry=Merged entry Merged\ entries=Merged entries None=None Parse=Parse @@ -1277,6 +1278,7 @@ Keep\ left=Keep left Keep\ right=Keep right Old\ entry=Old entry From\ import=From import +From\ DOI=From DOI No\ problems\ found.=No problems found. Save\ changes=Save changes Discard\ changes=Discard changes @@ -2367,3 +2369,13 @@ Found\ match\ in\ %0=Found match in %0 Grobid\ URL=Grobid URL Remote\ services=Remote services Allow\ sending\ PDF\ files\ and\ raw\ citation\ strings\ to\ a\ JabRef\ online\ service\ (Grobid)\ to\ determine\ Metadata.\ This\ produces\ better\ results.=Allow sending PDF files and raw citation strings to a JabRef online service (Grobid) to determine Metadata. This produces better results. + +Character\ by\ character=Character by character +Embedded=Embedded +Entry=Entry +Parse\ Metadata\ from\ PDF.=Parse Metadata from PDF. +Symmetric\ character\ by\ character=Symmetric character by character +Symmetric\ word\ by\ word=Symmetric word by word +Verbatim=Verbatim +Word\ by\ word=Word by word +Could\ not\ extract\ Metadata\ from\:\ %0=Could not extract Metadata from: %0 diff --git a/src/test/java/org/jabref/gui/fieldeditors/LinkedFileViewModelTest.java b/src/test/java/org/jabref/gui/fieldeditors/LinkedFileViewModelTest.java index b5443699f57..5b78bd0d6c2 100644 --- a/src/test/java/org/jabref/gui/fieldeditors/LinkedFileViewModelTest.java +++ b/src/test/java/org/jabref/gui/fieldeditors/LinkedFileViewModelTest.java @@ -28,6 +28,7 @@ import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.LinkedFile; import org.jabref.preferences.FilePreferences; +import org.jabref.preferences.PreferencesService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; @@ -60,7 +61,7 @@ class LinkedFileViewModelTest { private DialogService dialogService; private final ExternalFileTypes externalFileType = mock(ExternalFileTypes.class); private final FilePreferences filePreferences = mock(FilePreferences.class); - private final XmpPreferences xmpPreferences = mock(XmpPreferences.class); + private final PreferencesService preferences = mock(PreferencesService.class); private CookieManager cookieManager; @BeforeEach @@ -76,6 +77,8 @@ void setUp(@TempDir Path tempFolder) throws Exception { when(externalFileType.getExternalFileTypeByMimeType(contains("text/html"))).thenReturn(Optional.of(StandardExternalFileType.URL)); when(externalFileType.getExternalFileTypeByExt("pdf")).thenReturn(Optional.of(StandardExternalFileType.PDF)); when(externalFileType.getExternalFileTypeByExt("html")).thenReturn(Optional.of(StandardExternalFileType.URL)); + when(preferences.getFilePreferences()).thenReturn(filePreferences); + when(preferences.getXmpPreferences()).thenReturn(mock(XmpPreferences.class)); tempFile = tempFolder.resolve("temporaryFile"); Files.createFile(tempFile); @@ -100,7 +103,7 @@ void deleteWhenFilePathNotPresentReturnsTrue() { linkedFile = spy(new LinkedFile("", Path.of("nonexistent file"), "")); doReturn(Optional.empty()).when(linkedFile).findIn(any(BibDatabaseContext.class), any(FilePreferences.class)); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); boolean removed = viewModel.delete(); assertTrue(removed); @@ -118,7 +121,7 @@ void deleteWhenRemoveChosenReturnsTrueButDoesNotDeletesFile() { any(ButtonType.class), any(ButtonType.class))).thenAnswer(invocation -> Optional.of(invocation.getArgument(3))); // first vararg - remove button - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); boolean removed = viewModel.delete(); assertTrue(removed); @@ -136,7 +139,7 @@ void deleteWhenDeleteChosenReturnsTrueAndDeletesFile() { any(ButtonType.class), any(ButtonType.class))).thenAnswer(invocation -> Optional.of(invocation.getArgument(4))); // second vararg - delete button - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); boolean removed = viewModel.delete(); assertTrue(removed); @@ -154,7 +157,7 @@ void deleteMissingFileReturnsTrue() { any(ButtonType.class), any(ButtonType.class))).thenAnswer(invocation -> Optional.of(invocation.getArgument(4))); // second vararg - delete button - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); boolean removed = viewModel.delete(); assertTrue(removed); @@ -171,7 +174,7 @@ void deleteWhenDialogCancelledReturnsFalseAndDoesNotRemoveFile() { any(ButtonType.class), any(ButtonType.class))).thenAnswer(invocation -> Optional.of(invocation.getArgument(5))); // third vararg - cancel button - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); boolean removed = viewModel.delete(); assertFalse(removed); @@ -189,7 +192,7 @@ void downloadHtmlFileCausesWarningDisplay() throws MalformedURLException { String fileType = StandardExternalFileType.URL.getName(); linkedFile = new LinkedFile(url, fileType); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, preferences, externalFileType); viewModel.download(); @@ -204,7 +207,7 @@ void downloadDoesNotOverwriteFileTypeExtension() throws MalformedURLException { when(filePreferences.getFileNamePattern()).thenReturn("[citationkey]"); when(filePreferences.getFileDirectoryPattern()).thenReturn(""); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, preferences, externalFileType); BackgroundTask task = viewModel.prepareDownloadTask(tempFile.getParent(), new URLDownload("http://arxiv.org/pdf/1207.0408v1")); task.onSuccess(destination -> { @@ -229,7 +232,7 @@ void downloadHtmlWhenLinkedFilePointsToHtml() throws MalformedURLException { databaseContext.setDatabasePath(tempFile); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, preferences, externalFileType); viewModel.download(); @@ -252,7 +255,7 @@ void isNotSamePath() { when(filePreferences.getFileNamePattern()).thenReturn("[citationkey]"); when(databaseContext.getFirstExistingFileDir(filePreferences)).thenReturn(Optional.of(Path.of("/home"))); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); assertFalse(viewModel.isGeneratedPathSameAsOriginal()); } @@ -263,7 +266,7 @@ void isSamePath() { when(filePreferences.getFileNamePattern()).thenReturn("[citationkey]"); when(databaseContext.getFirstExistingFileDir(filePreferences)).thenReturn(Optional.of(tempFile.getParent())); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, taskExecutor, dialogService, preferences, externalFileType); assertTrue(viewModel.isGeneratedPathSameAsOriginal()); } @@ -286,7 +289,7 @@ void downloadPdfFileWhenLinkedFilePointsToPdfUrl() throws MalformedURLException databaseContext.setDatabasePath(tempFile); - LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, xmpPreferences, filePreferences, externalFileType); + LinkedFileViewModel viewModel = new LinkedFileViewModel(linkedFile, entry, databaseContext, new CurrentThreadTaskExecutor(), dialogService, preferences, externalFileType); viewModel.download(); // Loop through downloaded files to check for filetype='pdf'