diff --git a/src/main/java/org/jabref/gui/actions/Action.java b/src/main/java/org/jabref/gui/actions/Action.java index e6ccfe487b2..28859e00225 100644 --- a/src/main/java/org/jabref/gui/actions/Action.java +++ b/src/main/java/org/jabref/gui/actions/Action.java @@ -1,5 +1,6 @@ package org.jabref.gui.actions; +import java.util.Objects; import java.util.Optional; import org.jabref.gui.icon.JabRefIcon; @@ -19,4 +20,71 @@ default Optional getKeyBinding() { default String getDescription() { return ""; } + + class Builder { + private final ActionImpl actionImpl; + + public Builder(String text) { + this.actionImpl = new ActionImpl(); + setText(text); + } + + public Builder() { + this(""); + } + + public Action setIcon(JabRefIcon icon) { + Objects.requireNonNull(icon); + actionImpl.icon = icon; + return actionImpl; + } + + public Action setText(String text) { + Objects.requireNonNull(text); + actionImpl.text = text; + return actionImpl; + } + + public Action setKeyBinding(KeyBinding keyBinding) { + Objects.requireNonNull(keyBinding); + actionImpl.keyBinding = keyBinding; + return actionImpl; + } + + public Action setDescription(String description) { + Objects.requireNonNull(description); + actionImpl.description = description; + return actionImpl; + } + } + + class ActionImpl implements Action { + private JabRefIcon icon; + private KeyBinding keyBinding; + private String text; + private String description; + + private ActionImpl() { + } + + @Override + public Optional getIcon() { + return Optional.ofNullable(icon); + } + + @Override + public Optional getKeyBinding() { + return Optional.ofNullable(keyBinding); + } + + @Override + public String getText() { + return text != null ? text : ""; + } + + @Override + public String getDescription() { + return description != null ? description : ""; + } + } } diff --git a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java index cec54b0d192..6a89a239500 100644 --- a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java +++ b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java @@ -138,4 +138,12 @@ private void init(BibEntry one, BibEntry two, DuplicateResolverType type) { public BibEntry getMergedEntry() { return threeWayMerge.getMergedEntry(); } + + public BibEntry getNewLeftEntry() { + return threeWayMerge.getLeftEntry(); + } + + public BibEntry getNewRightEntry() { + return threeWayMerge.getRightEntry(); + } } diff --git a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateSearch.java b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateSearch.java index 7ccc1b19402..46ddfaf6f3b 100644 --- a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateSearch.java +++ b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateSearch.java @@ -152,16 +152,21 @@ private void askResolveStrategy(DuplicateSearchResult result, BibEntry first, Bi if ((resolverResult == DuplicateResolverResult.KEEP_LEFT) || (resolverResult == DuplicateResolverResult.AUTOREMOVE_EXACT)) { result.remove(second); + result.replace(first, dialog.getNewLeftEntry()); if (resolverResult == DuplicateResolverResult.AUTOREMOVE_EXACT) { autoRemoveExactDuplicates.set(true); // Remember choice } } else if (resolverResult == DuplicateResolverResult.KEEP_RIGHT) { result.remove(first); + result.replace(second, dialog.getNewRightEntry()); } else if (resolverResult == DuplicateResolverResult.BREAK) { libraryAnalyzed.set(true); duplicates.clear(); } else if (resolverResult == DuplicateResolverResult.KEEP_MERGE) { result.replace(first, second, dialog.getMergedEntry()); + } else if (resolverResult == DuplicateResolverResult.KEEP_BOTH) { + result.replace(first, dialog.getNewLeftEntry()); + result.replace(second, dialog.getNewRightEntry()); } } @@ -225,6 +230,11 @@ public synchronized void replace(BibEntry first, BibEntry second, BibEntry repla duplicates++; } + public synchronized void replace(BibEntry entry, BibEntry replacement) { + remove(entry); + getToAdd().add(replacement); + } + public synchronized boolean isToRemove(BibEntry entry) { return toRemove.containsKey(System.identityHashCode(entry)); } diff --git a/src/main/java/org/jabref/gui/icon/IconTheme.java b/src/main/java/org/jabref/gui/icon/IconTheme.java index 8ef3ed5d490..80eb1824a9b 100644 --- a/src/main/java/org/jabref/gui/icon/IconTheme.java +++ b/src/main/java/org/jabref/gui/icon/IconTheme.java @@ -343,7 +343,9 @@ public enum JabRefIcons implements JabRefIcon { ACCEPT_LEFT(MaterialDesignS.SUBDIRECTORY_ARROW_LEFT), - ACCEPT_RIGHT(MaterialDesignS.SUBDIRECTORY_ARROW_RIGHT); + ACCEPT_RIGHT(MaterialDesignS.SUBDIRECTORY_ARROW_RIGHT), + + MERGE_GROUPS(MaterialDesignS.SOURCE_MERGE); private final JabRefIcon icon; diff --git a/src/main/java/org/jabref/gui/mergeentries/FetchAndMergeEntry.java b/src/main/java/org/jabref/gui/mergeentries/FetchAndMergeEntry.java index ed7220b532e..37d3bbf0750 100644 --- a/src/main/java/org/jabref/gui/mergeentries/FetchAndMergeEntry.java +++ b/src/main/java/org/jabref/gui/mergeentries/FetchAndMergeEntry.java @@ -99,7 +99,7 @@ private void showMergeDialog(BibEntry originalEntry, BibEntry fetchedEntry, WebF dialog.setTitle(Localization.lang("Merge entry with %0 information", fetcher.getName())); dialog.setLeftHeaderText(Localization.lang("Original entry")); dialog.setRightHeaderText(Localization.lang("Entry from %0", fetcher.getName())); - Optional mergedEntry = dialogService.showCustomDialogAndWait(dialog); + Optional mergedEntry = dialogService.showCustomDialogAndWait(dialog).map(MergeResult::mergedEntry); if (mergedEntry.isPresent()) { NamedCompound ce = new NamedCompound(Localization.lang("Merge entry with %0 information", fetcher.getName())); diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesAction.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesAction.java index 3a24e84246b..7820cd95c92 100644 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesAction.java +++ b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesAction.java @@ -66,28 +66,24 @@ public void execute() { second = one; } - MergeEntriesDialog dlg = new MergeEntriesDialog(first, second); - dlg.setTitle(Localization.lang("Merge entries")); - Optional mergedEntry = dialogService.showCustomDialogAndWait(dlg); - if (mergedEntry.isPresent()) { - // ToDo: BibDatabase::insertEntry does not contain logic to mark the BasePanel as changed and to mark + MergeEntriesDialog dialog = new MergeEntriesDialog(first, second); + dialog.setTitle(Localization.lang("Merge entries")); + Optional mergeResultOpt = dialogService.showCustomDialogAndWait(dialog); + mergeResultOpt.ifPresentOrElse(mergeResult -> { + // TODO: BibDatabase::insertEntry does not contain logic to mark the BasePanel as changed and to mark // entries with a timestamp, only BasePanel::insertEntry does. Workaround for the moment is to get the // BasePanel from the constructor injected JabRefFrame. Should be refactored and extracted! - frame.getCurrentLibraryTab().insertEntry(mergedEntry.get()); + frame.getCurrentLibraryTab().insertEntry(mergeResult.mergedEntry()); - // Create a new entry and add it to the undo stack - // Remove the other two entries and add them to the undo stack (which is not working...) NamedCompound ce = new NamedCompound(Localization.lang("Merge entries")); - ce.addEdit(new UndoableInsertEntries(databaseContext.getDatabase(), mergedEntry.get())); + ce.addEdit(new UndoableInsertEntries(databaseContext.getDatabase(), mergeResult.mergedEntry())); List entriesToRemove = Arrays.asList(one, two); ce.addEdit(new UndoableRemoveEntries(databaseContext.getDatabase(), entriesToRemove)); databaseContext.getDatabase().removeEntries(entriesToRemove); ce.end(); - Globals.undoManager.addEdit(ce); // ToDo: Rework UndoManager and extract Globals + Globals.undoManager.addEdit(ce); dialogService.notify(Localization.lang("Merged entries")); - } else { - dialogService.notify(Localization.lang("Canceled merging entries")); - } + }, () -> dialogService.notify(Localization.lang("Canceled merging entries"))); } } diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java index 3486a9702d2..a93598494b0 100644 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java +++ b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java @@ -8,7 +8,7 @@ import org.jabref.logic.l10n.Localization; import org.jabref.model.entry.BibEntry; -public class MergeEntriesDialog extends BaseDialog { +public class MergeEntriesDialog extends BaseDialog { private final ThreeWayMergeView threeWayMergeView; public MergeEntriesDialog(BibEntry one, BibEntry two) { @@ -31,7 +31,7 @@ private void init() { this.getDialogPane().getButtonTypes().setAll(ButtonType.CANCEL, replaceEntries); this.setResultConverter(buttonType -> { if (buttonType.equals(replaceEntries)) { - return threeWayMergeView.getMergedEntry(); + return new MergeResult(threeWayMergeView.getLeftEntry(), threeWayMergeView.getRightEntry(), threeWayMergeView.getMergedEntry()); } else { return null; } diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeResult.java b/src/main/java/org/jabref/gui/mergeentries/MergeResult.java new file mode 100644 index 00000000000..6db5a7a60e5 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/MergeResult.java @@ -0,0 +1,8 @@ +package org.jabref.gui.mergeentries; + +import org.jabref.model.entry.BibEntry; + +public record MergeResult( + BibEntry leftEntry, BibEntry rightEntry, BibEntry mergedEntry +) { +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowView.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowView.java new file mode 100644 index 00000000000..78888ba787c --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowView.java @@ -0,0 +1,248 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import javax.swing.undo.AbstractUndoableEdit; +import javax.swing.undo.CannotRedoException; +import javax.swing.undo.CannotUndoException; +import javax.swing.undo.CompoundEdit; + +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.GridPane; + +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.mergeentries.newmergedialog.cell.FieldNameCell; +import org.jabref.gui.mergeentries.newmergedialog.cell.FieldValueCell; +import org.jabref.gui.mergeentries.newmergedialog.cell.MergedFieldCell; +import org.jabref.gui.mergeentries.newmergedialog.cell.sidebuttons.ToggleMergeUnmergeButton; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.SplitDiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.UnifiedDiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.fieldsmerger.FieldMerger; +import org.jabref.gui.mergeentries.newmergedialog.fieldsmerger.FieldMergerFactory; +import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; + +import com.tobiasdiez.easybind.EasyBind; +import org.fxmisc.richtext.StyleClassedTextArea; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.jabref.gui.mergeentries.newmergedialog.FieldRowViewModel.Selection; + +/** + * A controller class to control left, right and merged field values + */ +public class FieldRowView { + private static final Logger LOGGER = LoggerFactory.getLogger(FieldRowView.class); + private final FieldNameCell fieldNameCell; + private final FieldValueCell leftValueCell; + private final FieldValueCell rightValueCell; + private final MergedFieldCell mergedValueCell; + + private final ToggleGroup toggleGroup = new ToggleGroup(); + + private final FieldRowViewModel viewModel; + + private final CompoundEdit fieldsMergedEdit = new CompoundEdit(); + + public FieldRowView(Field field, BibEntry leftEntry, BibEntry rightEntry, BibEntry mergedEntry, FieldMergerFactory fieldMergerFactory, int rowIndex) { + viewModel = new FieldRowViewModel(field, leftEntry, rightEntry, mergedEntry); + + fieldNameCell = new FieldNameCell(field.getDisplayName(), rowIndex); + leftValueCell = new FieldValueCell(viewModel.getLeftFieldValue(), rowIndex); + rightValueCell = new FieldValueCell(viewModel.getRightFieldValue(), rowIndex); + mergedValueCell = new MergedFieldCell(viewModel.getMergedFieldValue(), rowIndex); + + if (FieldMergerFactory.canMerge(field)) { + ToggleMergeUnmergeButton toggleMergeUnmergeButton = new ToggleMergeUnmergeButton(field); + toggleMergeUnmergeButton.setCanMerge(!viewModel.hasEqualLeftAndRightValues()); + fieldNameCell.addSideButton(toggleMergeUnmergeButton); + + EasyBind.listen(toggleMergeUnmergeButton.fieldStateProperty(), ((observableValue, old, fieldState) -> { + LOGGER.debug("Field merge state is {} for field {}", fieldState, field); + if (fieldState == ToggleMergeUnmergeButton.FieldState.MERGED) { + new MergeCommand(fieldMergerFactory.create(field)).execute(); + } else { + new UnmergeCommand().execute(); + } + })); + } + + toggleGroup.getToggles().addAll(leftValueCell, rightValueCell); + + mergedValueCell.textProperty().bindBidirectional(viewModel.mergedFieldValueProperty()); + leftValueCell.textProperty().bindBidirectional(viewModel.leftFieldValueProperty()); + rightValueCell.textProperty().bindBidirectional(viewModel.rightFieldValueProperty()); + + EasyBind.subscribe(viewModel.selectionProperty(), selection -> { + if (selection == Selection.LEFT) { + toggleGroup.selectToggle(leftValueCell); + } else if (selection == Selection.RIGHT) { + toggleGroup.selectToggle(rightValueCell); + } else if (selection == Selection.NONE) { + toggleGroup.selectToggle(null); + } + }); + + EasyBind.subscribe(toggleGroup.selectedToggleProperty(), selectedToggle -> { + if (selectedToggle == leftValueCell) { + selectLeftValue(); + } else if (selectedToggle == rightValueCell) { + selectRightValue(); + } else { + selectNone(); + } + }); + + // Hide rightValueCell and extend leftValueCell to 2 columns when fields are merged + EasyBind.subscribe(viewModel.isFieldsMergedProperty(), isFieldsMerged -> { + if (isFieldsMerged) { + rightValueCell.setVisible(false); + GridPane.setColumnSpan(leftValueCell, 2); + } else { + rightValueCell.setVisible(true); + GridPane.setColumnSpan(leftValueCell, 1); + } + }); + + EasyBind.listen(viewModel.hasEqualLeftAndRightBinding(), (obs, old, isEqual) -> { + if (isEqual) { + LOGGER.debug("Left and right values are equal, LEFT==RIGHT=={}", viewModel.getLeftFieldValue()); + hideDiff(); + } + }); + } + + public void selectLeftValue() { + viewModel.selectLeftValue(); + } + + public void selectRightValue() { + viewModel.selectRightValue(); + } + + public void selectNone() { + viewModel.selectNone(); + } + + public String getMergedValue() { + return mergedValueProperty().getValue(); + } + + public ReadOnlyStringProperty mergedValueProperty() { + return viewModel.mergedFieldValueProperty(); + } + + public FieldNameCell getFieldNameCell() { + return fieldNameCell; + } + + public FieldValueCell getLeftValueCell() { + return leftValueCell; + } + + public FieldValueCell getRightValueCell() { + return rightValueCell; + } + + public MergedFieldCell getMergedValueCell() { + return mergedValueCell; + } + + public void showDiff(ShowDiffConfig diffConfig) { + if (!rightValueCell.isVisible()) { + return; + } + LOGGER.debug("Showing diffs..."); + + StyleClassedTextArea leftLabel = leftValueCell.getStyleClassedLabel(); + StyleClassedTextArea rightLabel = rightValueCell.getStyleClassedLabel(); + // Clearing old diff styles based on previous diffConfig + hideDiff(); + if (diffConfig.diffView() == ThreeWayMergeToolbar.DiffView.UNIFIED) { + new UnifiedDiffHighlighter(leftLabel, rightLabel, diffConfig.diffHighlightingMethod()).highlight(); + } else { + new SplitDiffHighlighter(leftLabel, rightLabel, diffConfig.diffHighlightingMethod()).highlight(); + } + } + + public void hideDiff() { + if (!rightValueCell.isVisible()) { + return; + } + + LOGGER.debug("Hiding diffs..."); + + int leftValueLength = getLeftValueCell().getStyleClassedLabel().getLength(); + getLeftValueCell().getStyleClassedLabel().clearStyle(0, leftValueLength); + getLeftValueCell().getStyleClassedLabel().replaceText(viewModel.getLeftFieldValue()); + + int rightValueLength = getRightValueCell().getStyleClassedLabel().getLength(); + getRightValueCell().getStyleClassedLabel().clearStyle(0, rightValueLength); + getRightValueCell().getStyleClassedLabel().replaceText(viewModel.getRightFieldValue()); + } + + public class MergeCommand extends SimpleCommand { + private final FieldMerger fieldMerger; + + public MergeCommand(FieldMerger fieldMerger) { + this.fieldMerger = fieldMerger; + + this.executable.bind(viewModel.hasEqualLeftAndRightBinding().not()); + } + + @Override + public void execute() { + assert !viewModel.getLeftFieldValue().equals(viewModel.getRightFieldValue()); + + String oldLeftFieldValue = viewModel.getLeftFieldValue(); + String oldRightFieldValue = viewModel.getRightFieldValue(); + + String mergedFields = fieldMerger.merge(viewModel.getLeftFieldValue(), viewModel.getRightFieldValue()); + viewModel.setLeftFieldValue(mergedFields); + viewModel.setRightFieldValue(mergedFields); + + if (fieldsMergedEdit.canRedo()) { + fieldsMergedEdit.redo(); + } else { + fieldsMergedEdit.addEdit(new MergeFieldsUndo(oldLeftFieldValue, oldRightFieldValue, mergedFields)); + fieldsMergedEdit.end(); + } + } + } + + public class UnmergeCommand extends SimpleCommand { + @Override + public void execute() { + if (fieldsMergedEdit.canUndo()) { + fieldsMergedEdit.undo(); + } + } + } + + class MergeFieldsUndo extends AbstractUndoableEdit { + private final String oldLeft; + private final String oldRight; + private final String mergedFields; + + MergeFieldsUndo(String oldLeft, String oldRight, String mergedFields) { + this.oldLeft = oldLeft; + this.oldRight = oldRight; + this.mergedFields = mergedFields; + } + + @Override + public void undo() throws CannotUndoException { + super.undo(); + viewModel.setLeftFieldValue(oldLeft); + viewModel.setRightFieldValue(oldRight); + } + + @Override + public void redo() throws CannotRedoException { + super.redo(); + viewModel.setLeftFieldValue(mergedFields); + viewModel.setRightFieldValue(mergedFields); + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowViewModel.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowViewModel.java new file mode 100644 index 00000000000..06356de4177 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowViewModel.java @@ -0,0 +1,222 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.InternalField; +import org.jabref.model.entry.types.EntryTypeFactory; +import org.jabref.model.strings.StringUtil; + +import com.tobiasdiez.easybind.EasyBind; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class FieldRowViewModel { + public enum Selection { + LEFT, + RIGHT, + /** + * When the user types something into the merged field value and neither the left nor + * right values match it, NONE is selected + * */ + NONE + } + + private final Logger LOGGER = LoggerFactory.getLogger(FieldRowViewModel.class); + private final BooleanProperty isFieldsMerged = new SimpleBooleanProperty(Boolean.FALSE); + + private final ObjectProperty selection = new SimpleObjectProperty<>(); + + private final StringProperty leftFieldValue = new SimpleStringProperty(""); + private final StringProperty rightFieldValue = new SimpleStringProperty(""); + private final StringProperty mergedFieldValue = new SimpleStringProperty(""); + + private final Field field; + + private final BibEntry leftEntry; + + private final BibEntry rightEntry; + + private final BibEntry mergedEntry; + + private final BooleanBinding hasEqualLeftAndRight; + + public FieldRowViewModel(Field field, BibEntry leftEntry, BibEntry rightEntry, BibEntry mergedEntry) { + this.field = field; + this.leftEntry = leftEntry; + this.rightEntry = rightEntry; + this.mergedEntry = mergedEntry; + + if (field.equals(InternalField.TYPE_HEADER)) { + setLeftFieldValue(leftEntry.getType().getDisplayName()); + setRightFieldValue(rightEntry.getType().getDisplayName()); + } else { + setLeftFieldValue(leftEntry.getField(field).orElse("")); + setRightFieldValue(rightEntry.getField(field).orElse("")); + } + + EasyBind.listen(leftFieldValueProperty(), (obs, old, leftValue) -> leftEntry.setField(field, leftValue)); + EasyBind.listen(rightFieldValueProperty(), (obs, old, rightValue) -> rightEntry.setField(field, rightValue)); + EasyBind.listen(mergedFieldValueProperty(), (obs, old, mergedFieldValue) -> { + if (field.equals(InternalField.TYPE_HEADER)) { + getMergedEntry().setType(EntryTypeFactory.parse(mergedFieldValue)); + } else { + getMergedEntry().setField(field, mergedFieldValue); + } + }); + + hasEqualLeftAndRight = Bindings.createBooleanBinding(this::hasEqualLeftAndRightValues, leftFieldValueProperty(), rightFieldValueProperty()); + + selectNonEmptyValue(); + + EasyBind.listen(isFieldsMergedProperty(), (obs, old, areFieldsMerged) -> { + LOGGER.debug("Field are merged: {}", areFieldsMerged); + if (areFieldsMerged) { + selectLeftValue(); + } else { + selectNonEmptyValue(); + } + }); + + EasyBind.subscribe(selectionProperty(), selection -> { + LOGGER.debug("Selecting {}' value for field {}", selection, field.getDisplayName()); + switch (selection) { + case LEFT -> EasyBind.subscribe(leftFieldValueProperty(), this::setMergedFieldValue); + case RIGHT -> EasyBind.subscribe(rightFieldValueProperty(), this::setMergedFieldValue); + } + }); + + EasyBind.subscribe(mergedFieldValueProperty(), mergedValue -> { + LOGGER.debug("Merged value is {} for field {}", mergedValue, field.getDisplayName()); + if (mergedValue.equals(getLeftFieldValue())) { + selectLeftValue(); + } else if (getMergedFieldValue().equals(getRightFieldValue())) { + selectRightValue(); + } else { + selectNone(); + } + }); + + EasyBind.subscribe(hasEqualLeftAndRightBinding(), this::setIsFieldsMerged); + } + + public void selectNonEmptyValue() { + if (StringUtil.isNullOrEmpty(leftFieldValue.get())) { + selectRightValue(); + } else { + selectLeftValue(); + } + } + + public boolean hasEqualLeftAndRightValues() { + return leftFieldValue.get().equals(rightFieldValue.get()); + } + + public void selectLeftValue() { + setSelection(Selection.LEFT); + } + + public void selectRightValue() { + if (isIsFieldsMerged()) { + selectLeftValue(); + } else { + setSelection(Selection.RIGHT); + } + } + + public void selectNone() { + setSelection(Selection.NONE); + } + + public void setMergedFieldValue(String mergedFieldValue) { + mergedFieldValueProperty().set(mergedFieldValue); + } + + public StringProperty mergedFieldValueProperty() { + return mergedFieldValue; + } + + public String getMergedFieldValue() { + return mergedFieldValue.get(); + } + + public void merge() { + setIsFieldsMerged(true); + } + + public BooleanBinding hasEqualLeftAndRightBinding() { + return hasEqualLeftAndRight; + } + + public ObjectProperty selectionProperty() { + return selection; + } + + public void setSelection(Selection select) { + selectionProperty().set(select); + } + + public Selection getSelection() { + return selectionProperty().get(); + } + + public boolean isIsFieldsMerged() { + return isFieldsMerged.get(); + } + + public BooleanProperty isFieldsMergedProperty() { + return isFieldsMerged; + } + + public void setIsFieldsMerged(boolean isFieldsMerged) { + this.isFieldsMerged.set(isFieldsMerged); + } + + public String getLeftFieldValue() { + return leftFieldValue.get(); + } + + public StringProperty leftFieldValueProperty() { + return leftFieldValue; + } + + public void setLeftFieldValue(String leftFieldValue) { + this.leftFieldValue.set(leftFieldValue); + } + + public String getRightFieldValue() { + return rightFieldValue.get(); + } + + public StringProperty rightFieldValueProperty() { + return rightFieldValue; + } + + public void setRightFieldValue(String rightFieldValue) { + this.rightFieldValue.set(rightFieldValue); + } + + public Field getField() { + return field; + } + + public BibEntry getLeftEntry() { + return leftEntry; + } + + public BibEntry getRightEntry() { + return rightEntry; + } + + public BibEntry getMergedEntry() { + return mergedEntry; + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css index 416b55b308a..ac73d7593f0 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css @@ -43,3 +43,9 @@ -fx-font-weight: bold; -fx-padding: 1, 0, 1, 0; } + +.field-name .glyph-icon, +.field-name .ikonli-font-icon { + -fx-icon-size: 17; + -fx-icon-color: -jr-theme-text; +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java index 7ab42c83d2f..a61833456f8 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java @@ -7,19 +7,20 @@ import javafx.scene.layout.ColumnConstraints; import javafx.scene.layout.GridPane; import javafx.scene.layout.Priority; +import javafx.scene.layout.RowConstraints; import javafx.scene.layout.VBox; import javafx.stage.Screen; +import org.jabref.gui.Globals; +import org.jabref.gui.mergeentries.newmergedialog.fieldsmerger.FieldMergerFactory; import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; 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.InternalField; -import org.jabref.model.entry.types.EntryTypeFactory; public class ThreeWayMergeView extends VBox { - public static final int GRID_COLUMN_MIN_WIDTH = 250; + public static final String LEFT_DEFAULT_HEADER = Localization.lang("Left Entry"); public static final String RIGHT_DEFAULT_HEADER = Localization.lang("Right Entry"); @@ -33,11 +34,15 @@ public class ThreeWayMergeView extends VBox { private final GridPane mergeGridPane; private final ThreeWayMergeViewModel viewModel; - private final List fieldRowControllerList = new ArrayList<>(); + private final List fieldRows = new ArrayList<>(); + + private final FieldMergerFactory fieldMergerFactory; public ThreeWayMergeView(BibEntry leftEntry, BibEntry rightEntry, String leftHeader, String rightHeader) { getStylesheets().add(ThreeWayMergeView.class.getResource("ThreeWayMergeView.css").toExternalForm()); - viewModel = new ThreeWayMergeViewModel(leftEntry, rightEntry, leftHeader, rightHeader); + viewModel = new ThreeWayMergeViewModel((BibEntry) leftEntry.clone(), (BibEntry) rightEntry.clone(), leftHeader, rightHeader); + // TODO: Inject 'preferenceService' into the constructor + this.fieldMergerFactory = new FieldMergerFactory(Globals.prefs); mergeGridPane = new GridPane(); scrollPane = new ScrollPane(); @@ -71,9 +76,9 @@ private void initializeToolbar() { private void updateDiff() { if (toolbar.isShowDiffEnabled()) { - fieldRowControllerList.forEach(fieldRow -> fieldRow.showDiff(new ShowDiffConfig(toolbar.getDiffView(), toolbar.getDiffHighlightingMethod()))); + fieldRows.forEach(row -> row.showDiff(new ShowDiffConfig(toolbar.getDiffView(), toolbar.getDiffHighlightingMethod()))); } else { - fieldRowControllerList.forEach(FieldRowController::hideDiff); + fieldRows.forEach(FieldRowView::hideDiff); } } @@ -101,46 +106,27 @@ private void initializeMergeGridPane() { mergeGridPane.getColumnConstraints().addAll(fieldNameColumnConstraints, leftEntryColumnConstraints, rightEntryColumnConstraints, mergedEntryColumnConstraints); for (int fieldIndex = 0; fieldIndex < viewModel.allFieldsSize(); fieldIndex++) { - addFieldRow(fieldIndex); + addRow(fieldIndex); + + mergeGridPane.getRowConstraints().add(new RowConstraints()); } } - private void addFieldRow(int index) { - Field field = viewModel.allFields().get(index); + private Field getFieldAtIndex(int index) { + return viewModel.allFields().get(index); + } - String leftEntryValue; - String rightEntryValue; - if (field.equals(InternalField.TYPE_HEADER)) { - leftEntryValue = viewModel.getLeftEntry().getType().getDisplayName(); - rightEntryValue = viewModel.getRightEntry().getType().getDisplayName(); - } else { - leftEntryValue = viewModel.getLeftEntry().getField(field).orElse(""); - rightEntryValue = viewModel.getRightEntry().getField(field).orElse(""); - } + private void addRow(int fieldIndex) { + Field field = getFieldAtIndex(fieldIndex); - FieldRowController fieldRow = new FieldRowController(field.getDisplayName(), leftEntryValue, rightEntryValue, index); - fieldRowControllerList.add(fieldRow); - - fieldRow.mergedValueProperty().addListener((observable, old, mergedValue) -> { - if (field.equals(InternalField.TYPE_HEADER)) { - getMergedEntry().setType(EntryTypeFactory.parse(mergedValue)); - } else { - getMergedEntry().setField(field, mergedValue); - } - }); - if (field.equals(InternalField.TYPE_HEADER)) { - getMergedEntry().setType(EntryTypeFactory.parse(fieldRow.getMergedValue())); - } else { - getMergedEntry().setField(field, fieldRow.getMergedValue()); - } + FieldRowView fieldRow = new FieldRowView(field, getLeftEntry(), getRightEntry(), getMergedEntry(), fieldMergerFactory, fieldIndex); - if (fieldRow.hasEqualLeftAndRightValues()) { - mergeGridPane.add(fieldRow.getFieldNameCell(), 0, index, 1, 1); - mergeGridPane.add(fieldRow.getLeftValueCell(), 1, index, 2, 1); - mergeGridPane.add(fieldRow.getMergedValueCell(), 3, index, 1, 1); - } else { - mergeGridPane.addRow(index, fieldRow.getFieldNameCell(), fieldRow.getLeftValueCell(), fieldRow.getRightValueCell(), fieldRow.getMergedValueCell()); - } + fieldRows.add(fieldIndex, fieldRow); + + mergeGridPane.add(fieldRow.getFieldNameCell(), 0, fieldIndex); + mergeGridPane.add(fieldRow.getLeftValueCell(), 1, fieldIndex); + mergeGridPane.add(fieldRow.getRightValueCell(), 2, fieldIndex); + mergeGridPane.add(fieldRow.getMergedValueCell(), 3, fieldIndex); } public BibEntry getMergedEntry() { @@ -156,11 +142,11 @@ public void setRightHeader(String rightHeader) { } public void selectLeftEntryValues() { - fieldRowControllerList.forEach(FieldRowController::selectLeftValue); + fieldRows.forEach(FieldRowView::selectLeftValue); } public void selectRightEntryValues() { - fieldRowControllerList.forEach(FieldRowController::selectRightValue); + fieldRows.forEach(FieldRowView::selectRightValue); } public void showDiff(ShowDiffConfig diffConfig) { @@ -168,4 +154,12 @@ public void showDiff(ShowDiffConfig diffConfig) { toolbar.setDiffHighlightingMethod(diffConfig.diffHighlightingMethod()); toolbar.setShowDiff(true); } + + public BibEntry getLeftEntry() { + return viewModel.getLeftEntry(); + } + + public BibEntry getRightEntry() { + return viewModel.getRightEntry(); + } } diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldNameCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldNameCell.java index 7ad435d59b1..a435f623187 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldNameCell.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldNameCell.java @@ -1,14 +1,20 @@ package org.jabref.gui.mergeentries.newmergedialog.cell; +import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; /** - * A non-editable cell that contains the name of some field + * A readonly cell used to display the name of some field. */ -public class FieldNameCell extends AbstractCell { +public class FieldNameCell extends ThreeWayMergeCell { public static final String DEFAULT_STYLE_CLASS = "field-name"; + protected final HBox actionLayout = new HBox(); private final Label label = new Label(); + private final HBox labelBox = new HBox(label); + public FieldNameCell(String text, int rowIndex) { super(text, rowIndex); initialize(); @@ -17,10 +23,17 @@ public FieldNameCell(String text, int rowIndex) { private void initialize() { getStyleClass().add(DEFAULT_STYLE_CLASS); initializeLabel(); - getChildren().add(label); + getChildren().addAll(labelBox, actionLayout); } private void initializeLabel() { label.textProperty().bind(textProperty()); + HBox.setHgrow(labelBox, Priority.ALWAYS); + } + + public void addSideButton(Button sideButton) { + // TODO: Allow adding more than one side button + actionLayout.getChildren().clear(); + actionLayout.getChildren().setAll(sideButton); } } diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldValueCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldValueCell.java index f31639ccc25..04fda36975d 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldValueCell.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldValueCell.java @@ -40,7 +40,7 @@ /** * A readonly, selectable field cell that contains the value of some field */ -public class FieldValueCell extends AbstractCell implements Toggle { +public class FieldValueCell extends ThreeWayMergeCell implements Toggle { public static final Logger LOGGER = LoggerFactory.getLogger(FieldValueCell.class); public static final String DEFAULT_STYLE_CLASS = "merge-field-value"; @@ -100,7 +100,7 @@ private void initialize() { private void initializeLabel() { label.setEditable(false); label.setBackground(Background.fill(Color.TRANSPARENT)); - label.appendText(textProperty().get()); + EasyBind.subscribe(textProperty(), label::replaceText); label.setAutoHeight(true); label.setWrapText(true); label.setStyle("-fx-cursor: hand"); diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/HeaderCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/HeaderCell.java index 2fd82afd38b..76d3fceff0a 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/HeaderCell.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/HeaderCell.java @@ -3,12 +3,15 @@ import javafx.geometry.Insets; import javafx.scene.control.Label; -public class HeaderCell extends AbstractCell { +/** + * A readonly cell used to display the header of the ThreeWayMerge UI at the top of the layout. + * */ +public class HeaderCell extends ThreeWayMergeCell { public static final String DEFAULT_STYLE_CLASS = "merge-header-cell"; private final Label label = new Label(); public HeaderCell(String text) { - super(text, AbstractCell.NO_ROW_NUMBER); + super(text, ThreeWayMergeCell.NO_ROW_NUMBER); initialize(); } diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/MergedFieldCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/MergedFieldCell.java index 77f170541d6..89c591ee0ac 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/MergedFieldCell.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/MergedFieldCell.java @@ -10,7 +10,7 @@ import org.fxmisc.richtext.StyleClassedTextArea; -public class MergedFieldCell extends AbstractCell { +public class MergedFieldCell extends ThreeWayMergeCell { private static final String DEFAULT_STYLE_CLASS = "merged-field"; private final StyleClassedTextArea textArea = new StyleClassedTextArea(); diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/OpenExternalLinkAction.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/OpenExternalLinkAction.java index 102b192458a..1b5087b413a 100644 --- a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/OpenExternalLinkAction.java +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/OpenExternalLinkAction.java @@ -9,7 +9,7 @@ import org.jabref.model.strings.StringUtil; /** - * This action can open an Url and DOI + * A command for opening DOIs and URLs. This was created primarily for simplifying {@link FieldValueCell}. * */ public class OpenExternalLinkAction extends SimpleCommand { private final String urlOrDoi; diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/ThreeWayMergeCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/ThreeWayMergeCell.java new file mode 100644 index 00000000000..c3496fcecb6 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/ThreeWayMergeCell.java @@ -0,0 +1,82 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.BooleanPropertyBase; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.scene.layout.HBox; + +/** + * + */ +public abstract class ThreeWayMergeCell extends HBox { + public static final String ODD_PSEUDO_CLASS = "odd"; + public static final String EVEN_PSEUDO_CLASS = "even"; + public static final int NO_ROW_NUMBER = -1; + private static final String DEFAULT_STYLE_CLASS = "field-cell"; + private final StringProperty text = new SimpleStringProperty(); + private final BooleanProperty odd = new BooleanPropertyBase() { + @Override + public Object getBean() { + return ThreeWayMergeCell.this; + } + + @Override + public String getName() { + return "odd"; + } + + @Override + protected void invalidated() { + pseudoClassStateChanged(PseudoClass.getPseudoClass(ODD_PSEUDO_CLASS), get()); + pseudoClassStateChanged(PseudoClass.getPseudoClass(EVEN_PSEUDO_CLASS), !get()); + } + }; + + private final BooleanProperty even = new BooleanPropertyBase() { + @Override + public Object getBean() { + return ThreeWayMergeCell.this; + } + + @Override + public String getName() { + return "even"; + } + + @Override + protected void invalidated() { + pseudoClassStateChanged(PseudoClass.getPseudoClass(EVEN_PSEUDO_CLASS), get()); + pseudoClassStateChanged(PseudoClass.getPseudoClass(ODD_PSEUDO_CLASS), !get()); + } + }; + + public ThreeWayMergeCell(String text, int rowIndex) { + getStyleClass().add(DEFAULT_STYLE_CLASS); + if (rowIndex != NO_ROW_NUMBER) { + if (rowIndex % 2 == 1) { + odd.setValue(true); + } else { + even.setValue(true); + } + } + + setPadding(new Insets(8)); + + setText(text); + } + + public String getText() { + return textProperty().get(); + } + + public StringProperty textProperty() { + return text; + } + + public void setText(String text) { + textProperty().set(text); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/sidebuttons/ToggleMergeUnmergeButton.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/sidebuttons/ToggleMergeUnmergeButton.java new file mode 100644 index 00000000000..de958837778 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/sidebuttons/ToggleMergeUnmergeButton.java @@ -0,0 +1,91 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell.sidebuttons; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.control.Button; + +import org.jabref.gui.Globals; +import org.jabref.gui.actions.Action; +import org.jabref.gui.actions.ActionFactory; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.icon.IconTheme; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.field.Field; + +public class ToggleMergeUnmergeButton extends Button { + private final ObjectProperty fieldState = new SimpleObjectProperty<>(FieldState.UNMERGED); + private final BooleanProperty canMerge = new SimpleBooleanProperty(Boolean.TRUE); + + private final ActionFactory actionFactory = new ActionFactory(Globals.getKeyPrefs()); + + private final Field field; + + public ToggleMergeUnmergeButton(Field field) { + this.field = field; + setMaxHeight(Double.MAX_VALUE); + setFocusTraversable(false); + + configureMergeButton(); + this.disableProperty().bind(canMergeProperty().not()); + } + + private void configureMergeButton() { + Action mergeAction = new Action.Builder(Localization.lang("Merge %0", field.getDisplayName())) + .setIcon(IconTheme.JabRefIcons.MERGE_GROUPS); + + actionFactory.configureIconButton(mergeAction, new ToggleMergeUnmergeAction(), this); + } + + private void configureUnmergeButton() { + Action unmergeAction = new Action.Builder(Localization.lang("Unmerge %0", field.getDisplayName())) + .setIcon(IconTheme.JabRefIcons.UNDO); + actionFactory.configureIconButton(unmergeAction, new ToggleMergeUnmergeAction(), this); + } + + public ObjectProperty fieldStateProperty() { + return fieldState; + } + + private void setFieldState(FieldState fieldState) { + fieldStateProperty().set(fieldState); + } + + public FieldState getFieldState() { + return fieldState.get(); + } + + public BooleanProperty canMergeProperty() { + return canMerge; + } + + public boolean canMerge() { + return canMerge.get(); + } + + /** + * Setting {@code canMerge} to {@code false} will disable the merge/unmerge button + * */ + public void setCanMerge(boolean value) { + canMergeProperty().set(value); + } + + private class ToggleMergeUnmergeAction extends SimpleCommand { + + @Override + public void execute() { + if (fieldStateProperty().get() == FieldState.MERGED) { + setFieldState(FieldState.UNMERGED); + configureMergeButton(); + } else { + setFieldState(FieldState.MERGED); + configureUnmergeButton(); + } + } + } + + public enum FieldState { + MERGED, UNMERGED + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/CommentMerger.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/CommentMerger.java new file mode 100644 index 00000000000..9beb1819423 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/CommentMerger.java @@ -0,0 +1,14 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +import org.jabref.logic.util.OS; +import org.jabref.model.entry.field.StandardField; + +/** + * A merger for the {@link StandardField#COMMENT} field + * */ +public class CommentMerger implements FieldMerger { + @Override + public String merge(String fieldValueA, String fieldValueB) { + return fieldValueA + OS.NEWLINE + fieldValueB; + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMerger.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMerger.java new file mode 100644 index 00000000000..8119a892abb --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMerger.java @@ -0,0 +1,9 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +/** + * This class is responsible for taking two values for some field and merging them to into one value + * */ +@FunctionalInterface +public interface FieldMerger { + String merge(String fieldValueA, String fieldValueB); +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMergerFactory.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMergerFactory.java new file mode 100644 index 00000000000..68a68c0359f --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FieldMergerFactory.java @@ -0,0 +1,31 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +import org.jabref.model.entry.field.Field; +import org.jabref.model.entry.field.StandardField; +import org.jabref.preferences.PreferencesService; + +public class FieldMergerFactory { + private final PreferencesService preferencesService; + + public FieldMergerFactory(PreferencesService preferencesService) { + this.preferencesService = preferencesService; + } + + public FieldMerger create(Field field) { + if (field == StandardField.GROUPS) { + return new GroupMerger(); + } else if (field == StandardField.KEYWORDS) { + return new KeywordMerger(preferencesService); + } else if (field == StandardField.COMMENT) { + return new CommentMerger(); + } else if (field == StandardField.FILE) { + return new FileMerger(); + } else { + throw new IllegalArgumentException("No implementation found for merging the given field: " + field.getDisplayName()); + } + } + + public static boolean canMerge(Field field) { + return field == StandardField.GROUPS || field == StandardField.KEYWORDS || field == StandardField.COMMENT || field == StandardField.FILE; + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FileMerger.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FileMerger.java new file mode 100644 index 00000000000..4b4e2034226 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/FileMerger.java @@ -0,0 +1,34 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.jabref.logic.bibtex.FileFieldWriter; +import org.jabref.logic.importer.util.FileFieldParser; +import org.jabref.model.entry.LinkedFile; +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.strings.StringUtil; + +/** + * A merger for the {@link StandardField#FILE} field + * */ +public class FileMerger implements FieldMerger { + @Override + public String merge(String filesA, String filesB) { + if (StringUtil.isBlank(filesA + filesB)) { + return ""; + } else if (StringUtil.isBlank(filesA)) { + return filesB; + } else if (StringUtil.isBlank(filesB)) { + return filesA; + } else { + List linkedFilesA = FileFieldParser.parse(filesA); + List linkedFilesB = FileFieldParser.parse(filesB); + // TODO: If one of the linked files list is empty then the its string value is malformed. + return Stream.concat(linkedFilesA.stream(), linkedFilesB.stream()) + .map(FileFieldWriter::getStringRepresentation) + .collect(Collectors.joining()); + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/GroupMerger.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/GroupMerger.java new file mode 100644 index 00000000000..fdbbddbb268 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/GroupMerger.java @@ -0,0 +1,29 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +import java.util.Arrays; +import java.util.stream.Collectors; + +import org.jabref.model.entry.field.StandardField; +import org.jabref.model.strings.StringUtil; + +/** + * A merger for the {@link StandardField#GROUPS} field + * */ +public class GroupMerger implements FieldMerger { + public static final String GROUPS_SEPARATOR = ", "; + + @Override + public String merge(String groupsA, String groupsB) { + if (StringUtil.isBlank(groupsA) && StringUtil.isBlank(groupsB)) { + return ""; + } else if (StringUtil.isBlank(groupsA)) { + return groupsB; + } else if (StringUtil.isBlank(groupsB)) { + return groupsA; + } else { + return Arrays.stream((groupsA + GROUPS_SEPARATOR + groupsB).split(GROUPS_SEPARATOR)) + .distinct() + .collect(Collectors.joining(GROUPS_SEPARATOR)); + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/KeywordMerger.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/KeywordMerger.java new file mode 100644 index 00000000000..3947a185b53 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/fieldsmerger/KeywordMerger.java @@ -0,0 +1,25 @@ +package org.jabref.gui.mergeentries.newmergedialog.fieldsmerger; + +import java.util.Objects; + +import org.jabref.model.entry.KeywordList; +import org.jabref.model.entry.field.StandardField; +import org.jabref.preferences.PreferencesService; + +/** + * A merger for the {@link StandardField#KEYWORDS} field + * */ +public class KeywordMerger implements FieldMerger { + private final PreferencesService preferencesService; + + public KeywordMerger(PreferencesService preferencesService) { + Objects.requireNonNull(preferencesService); + this.preferencesService = preferencesService; + } + + @Override + public String merge(String keywordsA, String keywordsB) { + Character delimiter = preferencesService.getGroupsPreferences().getKeywordSeparator(); + return KeywordList.merge(keywordsA, keywordsB, delimiter).getAsString(delimiter); + } +} diff --git a/src/main/java/org/jabref/gui/shared/SharedDatabaseUIManager.java b/src/main/java/org/jabref/gui/shared/SharedDatabaseUIManager.java index 055ea30a983..e5c2a0e9732 100644 --- a/src/main/java/org/jabref/gui/shared/SharedDatabaseUIManager.java +++ b/src/main/java/org/jabref/gui/shared/SharedDatabaseUIManager.java @@ -16,6 +16,7 @@ import org.jabref.gui.entryeditor.EntryEditor; import org.jabref.gui.exporter.SaveDatabaseAction; import org.jabref.gui.mergeentries.MergeEntriesDialog; +import org.jabref.gui.mergeentries.MergeResult; import org.jabref.gui.undo.UndoableRemoveEntries; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.l10n.Localization; @@ -99,7 +100,7 @@ public void listen(UpdateRefusedEvent updateRefusedEvent) { if (response.isPresent() && response.get().equals(merge)) { MergeEntriesDialog dialog = new MergeEntriesDialog(localBibEntry, sharedBibEntry); - Optional mergedEntry = dialogService.showCustomDialogAndWait(dialog); + Optional mergedEntry = dialogService.showCustomDialogAndWait(dialog).map(MergeResult::mergedEntry); mergedEntry.ifPresent(mergedBibEntry -> { mergedBibEntry.getSharedBibEntryData().setSharedID(sharedBibEntry.getSharedBibEntryData().getSharedID()); diff --git a/src/main/java/org/jabref/model/entry/KeywordList.java b/src/main/java/org/jabref/model/entry/KeywordList.java index 87f9b256f17..d54de57f977 100644 --- a/src/main/java/org/jabref/model/entry/KeywordList.java +++ b/src/main/java/org/jabref/model/entry/KeywordList.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Objects; @@ -72,6 +73,13 @@ public static KeywordList parse(String keywordString, Character delimiter) { return parse(keywordString, delimiter, Keyword.DEFAULT_HIERARCHICAL_DELIMITER); } + public static KeywordList merge(String keywordStringA, String keywordStringB, Character delimiter) { + KeywordList keywordListA = parse(keywordStringA, delimiter); + KeywordList keywordListB = parse(keywordStringB, delimiter); + List distinctKeywords = Stream.concat(keywordListA.stream(), keywordListB.stream()).distinct().toList(); + return new KeywordList(distinctKeywords); + } + public KeywordList createClone() { return new KeywordList(this.keywordChains); } @@ -186,7 +194,7 @@ public boolean equals(Object o) { return false; } KeywordList keywords1 = (KeywordList) o; - return Objects.equals(keywordChains, keywords1.keywordChains); + return Objects.equals(new HashSet<>(keywordChains), new HashSet<>(keywords1.keywordChains)); } @Override diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index 1f9fbf41b2c..02266356a72 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -1306,8 +1306,6 @@ Help\ on\ Name\ Formatting=Help on Name Formatting Add\ new\ file\ type=Add new file type -Left\ Entry=Left Entry -Right\ Entry=Right Entry Original\ entry=Original entry No\ information\ added=No information added Select\ at\ least\ one\ entry\ to\ manage\ keywords.=Select at least one entry to manage keywords. @@ -1353,7 +1351,6 @@ should\ contain\ a\ valid\ page\ number\ range=should contain a valid page numbe No\ results\ found.=No results found. Found\ %0\ results.=Found %0 results. Invalid\ regular\ expression=Invalid regular expression -plain\ text=plain text This\ search\ contains\ entries\ in\ which\ any\ field\ contains\ the\ regular\ expression\ %0=This search contains entries in which any field contains the regular expression %0 This\ search\ contains\ entries\ in\ which\ any\ field\ contains\ the\ term\ %0=This search contains entries in which any field contains the term %0 This\ search\ contains\ entries\ in\ which=This search contains entries in which @@ -2482,13 +2479,11 @@ No\ data\ was\ found\ for\ the\ identifier=No data was found for the identifier Server\ not\ available=Server not available Fetching\ information\ using\ %0=Fetching information using %0 Look\ up\ identifier=Look up identifier - Bibliographic\ data\ not\ found.\ Cause\ is\ likely\ the\ client\ side.\ Please\ check\ connection\ and\ identifier\ for\ correctness.=Bibliographic data not found. Cause is likely the client side. Please check connection and identifier for correctness. Bibliographic\ data\ not\ found.\ Cause\ is\ likely\ the\ server\ side.\ Please\ try\ agan\ later.=Bibliographic data not found. Cause is likely the server side. Please try agan later. Error\ message\ %0=Error message %0 Identifier\ not\ found=Identifier not found - Error\ while\ writing\ metadata.\ See\ the\ error\ log\ for\ details.=Error while writing metadata. See the error log for details. Failed\ to\ write\ metadata,\ file\ %1\ not\ found.=Failed to write metadata, file %1 not found. Success\!\ Finished\ writing\ metadata.=Success! Finished writing metadata. @@ -2514,7 +2509,6 @@ Automatic\ field\ editor=Automatic field editor From=From Keep\ Modifications=Keep Modifications To=To - Open\ Link=Open Link Highlight\ words=Highlight words Highlight\ characters=Highlight characters @@ -2529,3 +2523,8 @@ Assign=Assign Do\ not\ assign=Do not assign Error\ occured\ %0=Error occured %0 +Left\ Entry=Left Entry +Merge\ %0=Merge %0 +Right\ Entry=Right Entry +Unmerge\ %0=Unmerge %0 +plain\ text=plain text diff --git a/src/test/java/org/jabref/model/entry/KeywordListTest.java b/src/test/java/org/jabref/model/entry/KeywordListTest.java index 6dbb9b08db5..0812991d371 100644 --- a/src/test/java/org/jabref/model/entry/KeywordListTest.java +++ b/src/test/java/org/jabref/model/entry/KeywordListTest.java @@ -88,4 +88,25 @@ public void parseTwoHierarchicalChains() throws Exception { assertEquals(new KeywordList(expectedOne, expectedTwo), KeywordList.parse("Parent1 > Node1 > Child1, Parent2 > Node2 > Child2", ',', '>')); } + + @Test + public void mergeTwoIdenticalKeywordsShouldReturnOnKeyword() { + assertEquals(new KeywordList("JabRef"), KeywordList.merge("JabRef", "JabRef", ',')); + } + + @Test + public void mergeOneEmptyKeywordAnAnotherNonEmptyShouldReturnTheNonEmptyKeyword() { + assertEquals(new KeywordList("JabRef"), KeywordList.merge("", "JabRef", ',')); + } + + @Test + public void mergeTwoDistinctKeywordsShouldReturnTheTwoKeywordsMerged() { + assertEquals(new KeywordList("Figma", "JabRef"), KeywordList.merge("Figma", "JabRef", ',')); + assertEquals(new KeywordList("JabRef", "Figma"), KeywordList.merge("Figma", "JabRef", ',')); + } + + @Test + public void mergeTwoListsOfKeywordsShouldReturnTheKeywordsMerged() { + assertEquals(new KeywordList("Figma", "Adobe", "JabRef", "Eclipse", "JetBrains"), KeywordList.merge("Figma, Adobe, JetBrains, Eclipse", "Adobe, JabRef", ',')); + } }