diff --git a/src/main/java/org/jabref/gui/Base.css b/src/main/java/org/jabref/gui/Base.css index 188182d5151..b4bb5c12cc3 100644 --- a/src/main/java/org/jabref/gui/Base.css +++ b/src/main/java/org/jabref/gui/Base.css @@ -1,4 +1,13 @@ + + .root { + -jr-row-odd-background: -fx-control-inner-background-alt; + -jr-row-even-background: -fx-control-inner-background; + /* + On light theme, the text is hard to see when it's on top of the accent color. This is an alternative lighter accent color + for better text visibility. + */ + -jr-accent-alt: derive(-jr-accent, 15%); /* The theme color and some derived colors from it are used for icons, tab-headers, marking of selected inputs and @@ -9,6 +18,7 @@ /* This theme is the original JabRef dark blue color */ -jr-theme: #50618F; -jr-accent: #a3b7e6; + -jr-transparent-accent: rgba(163, 183, 230, 0.16); -jr-selected: -jr-accent; -jr-checked: -jr-theme; -jr-hover: #0002; @@ -245,6 +255,23 @@ -jr-header-height: 3em; } +.unchanged { + -rtfx-background-color:#0000; +} + +.updated { + -rtfx-background-color: rgba(41, 166, 236, 0.66); +} + +.addition { + -rtfx-background-color: rgba(29, 209, 161, 0.5); +} + +.deletion { + -rtfx-background-color: rgba(255, 107, 107, 0.55); +} + + #frame { -fx-background-color: -jr-background-alt; } @@ -607,6 +634,27 @@ TextFlow > .tooltip-text-monospaced { -fx-background-insets: 0; } +.merge-field-value .action-icon { + -fx-blend-mode: multiply; + -fx-opacity: 69%; + -fx-icon-size: 14; + -fx-icon-color: -fx-text-background-color; +} + +.merge-field-value:disabled .action-icon { + -fx-opacity: 0%; +} + +.merge-header-cell { + -fx-border-width: 0 0 1 0; + -fx-border-color: -jr-gray-1; + -fx-background-color: -jr-row-even-background; +} + +.merge-header { + -fx-background-color: -jr-row-even-background; +} + .table-view .groupColumnBackground { -fx-stroke: -jr-gray-2; } diff --git a/src/main/java/org/jabref/gui/Dark.css b/src/main/java/org/jabref/gui/Dark.css index adaa1cd1a83..11e74ef6d1d 100644 --- a/src/main/java/org/jabref/gui/Dark.css +++ b/src/main/java/org/jabref/gui/Dark.css @@ -4,6 +4,9 @@ -jr-selected: -jr-accent; -jr-hover: #fff1; + -jr-row-odd-background: #272b38; + -jr-row-even-background: #212330; + -jr-accent-alt: -jr-accent; -jr-red: #b71c1f; -jr-light-red: #db1d2b; @@ -60,6 +63,18 @@ -js-summary-text-color-selected: derive( -fx-dark-text-color, 70%); } +.unchanged { + +} + +.addition { + -rtfx-background-color: -jr-green; +} + +.deletion { + -rtfx-background-color: -jr-red; +} + #previewBody { background-color: #272b38; /* -fx-control-inner-background*/ color: #7d8591; /* -fx-mid-text-color*/ @@ -82,6 +97,21 @@ -fx-background-color: -jr-hover; } +.merge-field-value .action-icon { + -fx-blend-mode: none; + -fx-opacity: 90%; +} + +.merge-header-cell { + -fx-border-width: 0 0 1 0; + -fx-border-color: -fx-outer-border; + -fx-background-color: -jr-row-odd-background; +} + +.merge-header { + -fx-background-color: -jr-row-odd-background; +} + .table-view .groupColumnBackground { -fx-stroke: -jr-gray-3; } diff --git a/src/main/java/org/jabref/gui/collab/EntryChangeViewModel.java b/src/main/java/org/jabref/gui/collab/EntryChangeViewModel.java index 3ebed73f187..421e4c1fcd9 100644 --- a/src/main/java/org/jabref/gui/collab/EntryChangeViewModel.java +++ b/src/main/java/org/jabref/gui/collab/EntryChangeViewModel.java @@ -5,8 +5,10 @@ import javafx.scene.control.Label; import javafx.scene.layout.VBox; -import org.jabref.gui.mergeentries.MergeEntries; -import org.jabref.gui.mergeentries.MergeEntries.DefaultRadioButtonSelectionMode; +import org.jabref.gui.mergeentries.newmergedialog.ShowDiffConfig; +import org.jabref.gui.mergeentries.newmergedialog.ThreeWayMergeView; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; import org.jabref.gui.undo.NamedCompound; import org.jabref.gui.undo.UndoableInsertEntries; import org.jabref.logic.l10n.Localization; @@ -17,7 +19,7 @@ class EntryChangeViewModel extends DatabaseChangeViewModel { private final BibEntry oldEntry; private final BibEntry newEntry; - private MergeEntries mergePanel; + private ThreeWayMergeView threeWayMergeView; public EntryChangeViewModel(BibEntry entry, BibEntry newEntry) { super(); @@ -37,9 +39,9 @@ public EntryChangeViewModel(BibEntry entry, BibEntry newEntry) { public void setAccepted(boolean accepted) { super.setAccepted(accepted); if (accepted) { - mergePanel.selectAllRightRadioButtons(); + threeWayMergeView.selectRightEntryValues(); } else { - mergePanel.selectAllLeftRadioButtons(); + threeWayMergeView.selectLeftEntryValues(); } } @@ -47,7 +49,7 @@ public void setAccepted(boolean accepted) { public void makeChange(BibDatabaseContext database, NamedCompound undoEdit) { this.description(); // Init dialog to prevent NPE database.getDatabase().removeEntry(oldEntry); - BibEntry mergedEntry = mergePanel.getMergedEntry(); + BibEntry mergedEntry = threeWayMergeView.getMergedEntry(); mergedEntry.setId(oldEntry.getId()); // Keep ID database.getDatabase().insertEntry(mergedEntry); undoEdit.addEdit(new UndoableInsertEntries(database.getDatabase(), oldEntry)); @@ -56,13 +58,15 @@ public void makeChange(BibDatabaseContext database, NamedCompound undoEdit) { @Override public Node description() { - mergePanel = new MergeEntries(oldEntry, newEntry, Localization.lang("In JabRef"), Localization.lang("On disk"), DefaultRadioButtonSelectionMode.LEFT); + threeWayMergeView = new ThreeWayMergeView(oldEntry, newEntry, Localization.lang("In JabRef"), Localization.lang("On disk")); + threeWayMergeView.selectLeftEntryValues(); + threeWayMergeView.showDiff(new ShowDiffConfig(ThreeWayMergeToolbar.DiffView.SPLIT, DiffHighlighter.DiffMethod.WORDS)); VBox container = new VBox(10); Label header = new Label(name); header.getStyleClass().add("sectionHeader"); container.getChildren().add(header); - container.getChildren().add(mergePanel); - VBox.setMargin(mergePanel, new Insets(5, 5, 5, 5)); + container.getChildren().add(threeWayMergeView); + VBox.setMargin(threeWayMergeView, new Insets(5, 5, 5, 5)); return container; } } diff --git a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java index 1dad9cae973..cec54b0d192 100644 --- a/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java +++ b/src/main/java/org/jabref/gui/duplicationFinder/DuplicateResolverDialog.java @@ -10,7 +10,7 @@ import org.jabref.gui.StateManager; import org.jabref.gui.duplicationFinder.DuplicateResolverDialog.DuplicateResolverResult; import org.jabref.gui.help.HelpAction; -import org.jabref.gui.mergeentries.MergeEntries; +import org.jabref.gui.mergeentries.newmergedialog.ThreeWayMergeView; import org.jabref.gui.util.BaseDialog; import org.jabref.gui.util.DialogWindowState; import org.jabref.logic.help.HelpFile; @@ -39,7 +39,7 @@ public enum DuplicateResolverResult { BREAK } - private MergeEntries mergeEntries; + private ThreeWayMergeView threeWayMerge; private final DialogService dialogService; public DuplicateResolverDialog(BibEntry one, BibEntry two, DuplicateResolverType type, BibDatabaseContext database, StateManager stateManager, DialogService dialogService) { @@ -69,14 +69,14 @@ private void init(BibEntry one, BibEntry two, DuplicateResolverType type) { first = new ButtonType(Localization.lang("Keep left"), ButtonData.APPLY); second = new ButtonType(Localization.lang("Keep right"), ButtonData.APPLY); both = new ButtonType(Localization.lang("Keep both"), ButtonData.APPLY); - mergeEntries = new MergeEntries(one, two); + threeWayMerge = new ThreeWayMergeView(one, two); break; case INSPECTION: first = new ButtonType(Localization.lang("Remove old entry"), ButtonData.APPLY); second = new ButtonType(Localization.lang("Remove entry from import"), ButtonData.APPLY); both = new ButtonType(Localization.lang("Keep both"), ButtonData.APPLY); - mergeEntries = new MergeEntries(one, two, Localization.lang("Old entry"), - Localization.lang("From import")); + threeWayMerge = new ThreeWayMergeView(one, two, Localization.lang("Old entry"), + Localization.lang("From import")); break; case DUPLICATE_SEARCH_WITH_EXACT: first = new ButtonType(Localization.lang("Keep left"), ButtonData.APPLY); @@ -85,14 +85,14 @@ private void init(BibEntry one, BibEntry two, DuplicateResolverType type) { removeExactVisible = true; - mergeEntries = new MergeEntries(one, two); + threeWayMerge = new ThreeWayMergeView(one, two); break; default: first = new ButtonType(Localization.lang("Import and remove old entry"), ButtonData.APPLY); second = new ButtonType(Localization.lang("Do not import entry"), ButtonData.APPLY); both = new ButtonType(Localization.lang("Import and keep old entry"), ButtonData.APPLY); - mergeEntries = new MergeEntries(one, two, Localization.lang("Old entry"), - Localization.lang("From import")); + threeWayMerge = new ThreeWayMergeView(one, two, Localization.lang("Old entry"), + Localization.lang("From import")); break; } if (removeExactVisible) { @@ -109,7 +109,7 @@ private void init(BibEntry one, BibEntry two, DuplicateResolverType type) { this.setY(state.getY()); } - BorderPane borderPane = new BorderPane(mergeEntries); + BorderPane borderPane = new BorderPane(threeWayMerge); borderPane.setBottom(options); this.setResultConverter(button -> { @@ -136,6 +136,6 @@ private void init(BibEntry one, BibEntry two, DuplicateResolverType type) { } public BibEntry getMergedEntry() { - return mergeEntries.getMergedEntry(); + return threeWayMerge.getMergedEntry(); } } diff --git a/src/main/java/org/jabref/gui/fieldeditors/URLUtil.java b/src/main/java/org/jabref/gui/fieldeditors/URLUtil.java index bb73b95a6d9..1ee8e8b2652 100644 --- a/src/main/java/org/jabref/gui/fieldeditors/URLUtil.java +++ b/src/main/java/org/jabref/gui/fieldeditors/URLUtil.java @@ -74,7 +74,12 @@ public static String cleanGoogleSearchURL(String url) { * @return true if url contains a valid URL */ public static boolean isURL(String url) { - return url.contains("://"); + try { + new URL(url); + return true; + } catch (MalformedURLException e) { + return false; + } } /** diff --git a/src/main/java/org/jabref/gui/icon/IconTheme.java b/src/main/java/org/jabref/gui/icon/IconTheme.java index cbaa6fac7d8..8ef3ed5d490 100644 --- a/src/main/java/org/jabref/gui/icon/IconTheme.java +++ b/src/main/java/org/jabref/gui/icon/IconTheme.java @@ -339,7 +339,11 @@ public enum JabRefIcons implements JabRefIcon { KEEP_SEARCH_STRING(MaterialDesignE.EARTH), KEEP_ON_TOP(MaterialDesignP.PIN), KEEP_ON_TOP_OFF(MaterialDesignP.PIN_OFF_OUTLINE), - OPEN_GLOBAL_SEARCH(MaterialDesignO.OPEN_IN_NEW); + OPEN_GLOBAL_SEARCH(MaterialDesignO.OPEN_IN_NEW), + + ACCEPT_LEFT(MaterialDesignS.SUBDIRECTORY_ARROW_LEFT), + + ACCEPT_RIGHT(MaterialDesignS.SUBDIRECTORY_ARROW_RIGHT); private final JabRefIcon icon; diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.css b/src/main/java/org/jabref/gui/mergeentries/MergeEntries.css deleted file mode 100644 index 82f5ca6d3ee..00000000000 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.css +++ /dev/null @@ -1,12 +0,0 @@ -.text-changed { - -fx-fill: darkgreen; -} - -.text-added { - -fx-fill: #54A3F2; -} - -.text-removed { - -fx-fill: #FA5B68; -} - diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java deleted file mode 100644 index 99472efdd89..00000000000 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntries.java +++ /dev/null @@ -1,417 +0,0 @@ -package org.jabref.gui.mergeentries; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; -import java.util.stream.Collectors; - -import javafx.collections.FXCollections; -import javafx.geometry.HPos; -import javafx.geometry.Insets; -import javafx.scene.control.Button; -import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; -import javafx.scene.control.RadioButton; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.ToggleGroup; -import javafx.scene.control.Tooltip; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.ColumnConstraints; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Priority; -import javafx.scene.text.Text; -import javafx.scene.text.TextFlow; -import javafx.stage.Screen; - -import org.jabref.gui.Globals; -import org.jabref.gui.icon.IconTheme.JabRefIcons; -import org.jabref.gui.util.ViewModelListCellFactory; -import org.jabref.gui.util.component.DiffHighlightingTextPane; -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 com.tobiasdiez.easybind.EasyBind; - -public class MergeEntries extends BorderPane { - - private static final int NUMBER_OF_COLUMNS = 6; - private static final int LEFT_RADIOBUTTON_INDEX = 0; - private static final int RIGHT_RADIOBUTTON_INDEX = 2; - private final ComboBox diffMode = new ComboBox<>(); - - // Headings - private final List columnHeadings = Arrays.asList( - Localization.lang("Field"), - Localization.lang("Left entry"), - "left icon", - Localization.lang("None"), - "right icon", - Localization.lang("Right entry")); - private final Set identicalFields = new HashSet<>(); - private final Set differentFields = new HashSet<>(); - private final BibEntry mergedEntry = new BibEntry(); - private final BibEntry leftEntry; - private final BibEntry rightEntry; - private final Map leftTextPanes = new HashMap<>(); - private final Set allFields = new TreeSet<>(Comparator.comparing(Field::getName)); - private final Map rightTextPanes = new HashMap<>(); - private final Map> radioButtons = new HashMap<>(); - private Boolean identicalTypes; - private List typeRadioButtons; - private final DefaultRadioButtonSelectionMode defaultRadioButtonSelectionMode; - private final List leftRadioButtons = new ArrayList<>(); - private final List rightRadioButtons = new ArrayList<>(); - - /** - * Constructor with optional column captions for the two entries - * - * @param entryLeft Left entry - * @param entryRight Right entry - * @param headingLeft Heading for left entry - * @param headingRight Heading for right entry - * @param defaultRadioButtonSelectionMode If the left or the right side of the radio button should be preselected - */ - public MergeEntries(BibEntry entryLeft, BibEntry entryRight, String headingLeft, String headingRight, DefaultRadioButtonSelectionMode defaultRadioButtonSelectionMode) { - this.leftEntry = entryLeft; - this.rightEntry = entryRight; - this.defaultRadioButtonSelectionMode = defaultRadioButtonSelectionMode; - - initialize(); - setLeftHeaderText(headingLeft); - setRightHeaderText(headingRight); - } - - /** - * Constructor with optional column captions for the two entries - * - * @param entryLeft Left entry - * @param entryRight Right entry - * @param headingLeft Heading for left entry - * @param headingRight Heading for right entry - */ - public MergeEntries(BibEntry entryLeft, BibEntry entryRight, String headingLeft, String headingRight) { - this(entryLeft, entryRight, headingLeft, headingRight, DefaultRadioButtonSelectionMode.LEFT); - } - - /** - * Constructor taking two entries - * - * @param entryLeft Left entry - * @param entryRight Right entry - * @param defaultRadioButtonSelectionMode If the left or the right side of the radio button should be preselected - */ - public MergeEntries(BibEntry entryLeft, BibEntry entryRight, DefaultRadioButtonSelectionMode defaultRadioButtonSelectionMode) { - leftEntry = entryLeft; - rightEntry = entryRight; - this.defaultRadioButtonSelectionMode = defaultRadioButtonSelectionMode; - initialize(); - } - - /** - * Constructor taking two entries - * - * @param entryLeft Left entry - * @param entryRight Right entry - */ - public MergeEntries(BibEntry entryLeft, BibEntry entryRight) { - this(entryLeft, entryRight, DefaultRadioButtonSelectionMode.LEFT); - } - - private static String getDisplayText(DiffMode mode) { - return switch (mode) { - case PLAIN -> Localization.lang("Plain text"); - case WORD -> Localization.lang("Show diff") + " - " + Localization.lang("word"); - case CHARACTER -> Localization.lang("Show diff") + " - " + Localization.lang("character"); - case WORD_SYMMETRIC -> Localization.lang("Show symmetric diff") + " - " + Localization.lang("word"); - case CHARACTER_SYMMETRIC -> Localization.lang("Show symmetric diff") + " - " + Localization.lang("character"); - }; - } - - /** - * Main function for building the merge entry JPanel - */ - private void initialize() { - this.setPrefHeight(Screen.getPrimary().getBounds().getHeight() * 0.75); - this.setPrefWidth(Screen.getPrimary().getBounds().getWidth() * 0.75); - - setupFields(); - - fillDiffModes(); - - GridPane mergePanel = new GridPane(); - mergePanel.setVgap(10); - mergePanel.setHgap(15); - ColumnConstraints columnLabel = new ColumnConstraints(); - columnLabel.setHgrow(Priority.ALWAYS); - ColumnConstraints columnValues = new ColumnConstraints(); - columnValues.setHgrow(Priority.NEVER); - columnValues.setPercentWidth(40); - ColumnConstraints columnSelect = new ColumnConstraints(); - columnSelect.setHgrow(Priority.NEVER); - columnSelect.setHalignment(HPos.CENTER); - // See columnHeadings variable for the headings: 1) field, 2) left content, 3) left arrow, 4) "none", 5) right arrow, 6) right content - mergePanel.getColumnConstraints().setAll(columnLabel, columnValues, columnSelect, columnSelect, columnSelect, columnValues); - - setupHeadingRows(mergePanel); - setupEntryTypeRow(mergePanel); - setupFieldRows(mergePanel); - - ScrollPane scrollPane = new ScrollPane(mergePanel); - scrollPane.setFitToWidth(true); - setCenter(scrollPane); - - updateFieldValues(allFields); - - updateMergedEntry(); - - getStylesheets().add(0, MergeEntries.class.getResource("MergeEntries.css").toExternalForm()); - } - - private void setupFieldRows(GridPane mergePanel) { - // For all fields in joint add a row and possibly radio buttons - int row = 2; - for (Field field : allFields) { - Label label = new Label(field.getDisplayName()); - label.setMinWidth(USE_PREF_SIZE); - mergePanel.add(label, 0, row); - Optional leftString = leftEntry.getField(field); - Optional rightString = rightEntry.getField(field); - if (leftString.equals(rightString)) { - identicalFields.add(field); - } else { - differentFields.add(field); - } - - // Left text pane - if (leftString.isPresent()) { - TextFlow tf = new DiffHighlightingTextPane(); - mergePanel.add(tf, 1, row); - leftTextPanes.put(field, tf); - } - - // Add radio buttons if the two entries do not have identical fields - if (identicalFields.contains(field)) { - mergedEntry.setField(field, leftString.get()); // Will only happen if both entries have the field and the content is identical - } else { - ToggleGroup group = new ToggleGroup(); - List list = new ArrayList<>(3); - for (int k = 0; k < 3; k++) { - RadioButton button = new RadioButton(); - EasyBind.subscribe(button.selectedProperty(), selected -> updateMergedEntry()); - group.getToggles().add(button); - mergePanel.add(button, 2 + k, row); - list.add(button); - } - radioButtons.put(field, list); - if (leftString.isPresent()) { - leftRadioButtons.add(list.get(LEFT_RADIOBUTTON_INDEX)); - list.get(LEFT_RADIOBUTTON_INDEX).setSelected(true); - if (rightString.isEmpty()) { - list.get(RIGHT_RADIOBUTTON_INDEX).setDisable(true); - } else if (this.defaultRadioButtonSelectionMode == DefaultRadioButtonSelectionMode.RIGHT) { - list.get(RIGHT_RADIOBUTTON_INDEX).setSelected(true); - rightRadioButtons.add(list.get(RIGHT_RADIOBUTTON_INDEX)); - } else { - rightRadioButtons.add(list.get(RIGHT_RADIOBUTTON_INDEX)); - } - } else { - list.get(LEFT_RADIOBUTTON_INDEX).setDisable(true); - list.get(RIGHT_RADIOBUTTON_INDEX).setSelected(true); - rightRadioButtons.add(list.get(RIGHT_RADIOBUTTON_INDEX)); - } - } - - // Right text pane - if (rightString.isPresent()) { - TextFlow tf = new DiffHighlightingTextPane(); - mergePanel.add(tf, 5, row); - rightTextPanes.put(field, tf); - } - row++; - } - } - - private void setupEntryTypeRow(GridPane mergePanel) { - // Start with entry type - int rowIndex = 1; - mergePanel.add(new Label(Localization.lang("Entry type")), 0, rowIndex); - if (leftEntry.getType().equals(rightEntry.getType())) { - mergePanel.add(DiffHighlighting.forUnchanged(leftEntry.getType().getDisplayName()), 1, rowIndex); - mergePanel.add(DiffHighlighting.forUnchanged(rightEntry.getType().getDisplayName()), 5, rowIndex); - identicalTypes = true; - } else { - mergePanel.add(DiffHighlighting.forChanged(leftEntry.getType().getDisplayName()), 1, rowIndex); - mergePanel.add(DiffHighlighting.forChanged(rightEntry.getType().getDisplayName()), 5, rowIndex); - identicalTypes = false; - ToggleGroup group = new ToggleGroup(); - typeRadioButtons = new ArrayList<>(2); - - for (int k = 0; k < 3; k += 2) { - RadioButton button = new RadioButton(); - EasyBind.subscribe(button.selectedProperty(), selected -> updateMergedEntry()); - typeRadioButtons.add(button); - group.getToggles().add(button); - mergePanel.add(button, 2 + k, rowIndex); - } - if (defaultRadioButtonSelectionMode == DefaultRadioButtonSelectionMode.RIGHT) { - typeRadioButtons.get(1).setSelected(true); // This Radio Button list does not have a third option as compared to the fields, so do not use the constants here - rightRadioButtons.add(typeRadioButtons.get(1)); - } else { - typeRadioButtons.get(0).setSelected(true); - leftRadioButtons.add(typeRadioButtons.get(0)); - } - } - } - - private void setupHeadingRows(GridPane mergePanel) { - // Set headings - for (int i = 0; i < NUMBER_OF_COLUMNS; i++) { - if (i == 2) { - Button selectAllLeft = new Button(); - selectAllLeft.setGraphic(JabRefIcons.LEFT.getGraphicNode()); - selectAllLeft.setOnAction(evt -> this.selectAllLeftRadioButtons()); - selectAllLeft.setTooltip(new Tooltip(Localization.lang("Select all changes on the left"))); - mergePanel.add(selectAllLeft, i, 0); - } else if (i == 4) { - Button selectAllRight = new Button(); - selectAllRight.setOnAction(evt -> this.selectAllRightRadioButtons()); - selectAllRight.setGraphic(JabRefIcons.RIGHT.getGraphicNode()); - selectAllRight.setTooltip(new Tooltip(Localization.lang("Select all changes on the right"))); - mergePanel.add(selectAllRight, i, 0); - } else { - Label colHeading = new Label(columnHeadings.get(i)); - colHeading.setMinWidth(USE_PREF_SIZE); - mergePanel.add(colHeading, i, 0); - } - } - } - - private void fillDiffModes() { - diffMode.setItems(FXCollections.observableList(Arrays.asList(DiffMode.values()))); - new ViewModelListCellFactory() - .withText(MergeEntries::getDisplayText) - .install(diffMode); - DiffMode diffModePref = Globals.prefs.getGuiPreferences().getMergeDiffMode(); - diffMode.setValue(diffModePref); - EasyBind.subscribe(this.diffMode.valueProperty(), mode -> { - updateFieldValues(differentFields); - Globals.prefs.getGuiPreferences().setMergeDiffMode(mode); - }); - - HBox heading = new HBox(10); - heading.getChildren().setAll(this.diffMode); - setTop(heading); - BorderPane.setMargin(heading, new Insets(0, 0, 10, 0)); - } - - private void setupFields() { - allFields.addAll(leftEntry.getFields()); - allFields.addAll(rightEntry.getFields()); - - // Do not show internal fields - Set internalFields = allFields.stream().filter(FieldFactory::isInternalField).collect(Collectors.toSet()); - allFields.removeAll(internalFields); - } - - private void updateFieldValues(Collection fields) { - for (Field field : fields) { - String leftString = leftEntry.getField(field).orElse(""); - String rightString = rightEntry.getField(field).orElse(""); - List leftText = leftString.isEmpty() ? Collections.emptyList() : Collections.singletonList(DiffHighlighting.forUnchanged(leftString)); - List rightText = rightString.isEmpty() ? Collections.emptyList() : Collections.singletonList(DiffHighlighting.forUnchanged(rightString)); - switch (diffMode.getValue()) { - case PLAIN: - break; - case WORD: - rightText = DiffHighlighting.generateDiffHighlighting(leftString, rightString, " "); - break; - case CHARACTER: - rightText = DiffHighlighting.generateDiffHighlighting(leftString, rightString, ""); - break; - case WORD_SYMMETRIC: - leftText = DiffHighlighting.generateSymmetricHighlighting(leftString, rightString, " "); - rightText = DiffHighlighting.generateSymmetricHighlighting(rightString, leftString, " "); - break; - case CHARACTER_SYMMETRIC: - leftText = DiffHighlighting.generateSymmetricHighlighting(leftString, rightString, ""); - rightText = DiffHighlighting.generateSymmetricHighlighting(rightString, leftString, ""); - break; - default: - throw new UnsupportedOperationException("Not implemented " + diffMode.getValue()); - } - if (!leftText.isEmpty() && leftTextPanes.containsKey(field)) { - leftTextPanes.get(field).getChildren().setAll(leftText); - } - if (!rightText.isEmpty() && rightTextPanes.containsKey(field)) { - rightTextPanes.get(field).getChildren().setAll(rightText); - } - } - } - - public void selectAllRightRadioButtons() { - for (RadioButton radioButton : rightRadioButtons) { - radioButton.setSelected(true); - } - } - - public void selectAllLeftRadioButtons() { - for (RadioButton radioButton : leftRadioButtons) { - radioButton.setSelected(true); - } - } - - public BibEntry getMergedEntry() { - return mergedEntry; - } - - private void updateMergedEntry() { - // Check if the type has changed - if (!identicalTypes && !typeRadioButtons.isEmpty() && typeRadioButtons.get(0).isSelected()) { - mergedEntry.setType(leftEntry.getType()); - } else { - mergedEntry.setType(rightEntry.getType()); - } - - // Check the potentially different fields - for (Field field : differentFields) { - if (!radioButtons.containsKey(field)) { - // May happen during initialization -> just ignore - continue; - } - if (radioButtons.get(field).get(LEFT_RADIOBUTTON_INDEX).isSelected()) { - mergedEntry.setField(field, leftEntry.getField(field).get()); // Will only happen if field exists - } else if (radioButtons.get(field).get(RIGHT_RADIOBUTTON_INDEX).isSelected()) { - mergedEntry.setField(field, rightEntry.getField(field).get()); // Will only happen if field exists - } else { - mergedEntry.clearField(field); - } - } - } - - public void setLeftHeaderText(String leftHeaderText) { - columnHeadings.set(1, leftHeaderText); - initialize(); - } - - public void setRightHeaderText(String rightHeaderText) { - columnHeadings.set(5, rightHeaderText); - initialize(); - } - - public enum DefaultRadioButtonSelectionMode { - LEFT, - RIGHT - } -} diff --git a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java index 65fb71648bd..3486a9702d2 100644 --- a/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java +++ b/src/main/java/org/jabref/gui/mergeentries/MergeEntriesDialog.java @@ -3,16 +3,16 @@ import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; +import org.jabref.gui.mergeentries.newmergedialog.ThreeWayMergeView; import org.jabref.gui.util.BaseDialog; import org.jabref.logic.l10n.Localization; import org.jabref.model.entry.BibEntry; public class MergeEntriesDialog extends BaseDialog { - - private final MergeEntries mergeEntries; + private final ThreeWayMergeView threeWayMergeView; public MergeEntriesDialog(BibEntry one, BibEntry two) { - mergeEntries = new MergeEntries(one, two); + threeWayMergeView = new ThreeWayMergeView(one, two); init(); } @@ -21,14 +21,17 @@ public MergeEntriesDialog(BibEntry one, BibEntry two) { * Sets up the dialog */ private void init() { - this.getDialogPane().setContent(mergeEntries); + this.setX(20); + this.setY(20); + + this.getDialogPane().setContent(threeWayMergeView); // Create buttons ButtonType replaceEntries = new ButtonType(Localization.lang("Merge entries"), ButtonBar.ButtonData.OK_DONE); this.getDialogPane().getButtonTypes().setAll(ButtonType.CANCEL, replaceEntries); this.setResultConverter(buttonType -> { if (buttonType.equals(replaceEntries)) { - return mergeEntries.getMergedEntry(); + return threeWayMergeView.getMergedEntry(); } else { return null; } @@ -36,10 +39,10 @@ private void init() { } public void setLeftHeaderText(String leftHeaderText) { - mergeEntries.setLeftHeaderText(leftHeaderText); + threeWayMergeView.setLeftHeader(leftHeaderText); } public void setRightHeaderText(String rightHeaderText) { - mergeEntries.setRightHeaderText(rightHeaderText); + threeWayMergeView.setRightHeader(rightHeaderText); } } diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowController.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowController.java new file mode 100644 index 00000000000..8f2411d89cf --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/FieldRowController.java @@ -0,0 +1,139 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.scene.control.ToggleGroup; + +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.diffhighlighter.SplitDiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.UnifiedDiffHighlighter; +import org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar; +import org.jabref.model.strings.StringUtil; + +import org.fxmisc.richtext.StyleClassedTextArea; + +public class FieldRowController { + private final FieldNameCell fieldNameCell; + private final FieldValueCell leftValueCell; + private FieldValueCell rightValueCell; + private final MergedFieldCell mergedValueCell; + + private final String leftValue; + + private final String rightValue; + + private final ToggleGroup toggleGroup = new ToggleGroup(); + + public FieldRowController(String fieldName, String leftValue, String rightValue, int rowIndex) { + fieldNameCell = new FieldNameCell(fieldName, rowIndex); + leftValueCell = new FieldValueCell(leftValue, rowIndex); + rightValueCell = new FieldValueCell(rightValue, rowIndex); + mergedValueCell = new MergedFieldCell(StringUtil.isNullOrEmpty(leftValue) ? rightValue : leftValue, rowIndex); + + this.leftValue = leftValue; + this.rightValue = rightValue; + + toggleGroup.getToggles().addAll(leftValueCell, rightValueCell); + toggleGroup.selectToggle(StringUtil.isNullOrEmpty(leftValue) ? rightValueCell : leftValueCell); + toggleGroup.selectedToggleProperty().addListener(invalidated -> { + if (toggleGroup.getSelectedToggle() != null) { + mergedValueCell.setText((String) toggleGroup.getSelectedToggle().getUserData()); + } + }); + + mergedValueCell.textProperty().addListener((observable, old, mergedValue) -> { + if (mergedValue.equals(leftValue)) { + toggleGroup.selectToggle(leftValueCell); + } else if (mergedValue.equals(rightValue)) { + toggleGroup.selectToggle(rightValueCell); + } else { + // deselect all toggles because left and right values don't equal the merged value + toggleGroup.selectToggle(null); + } + }); + + // When both the left and right cells have the same value, only the left value is displayed, + // making it unnecessary to keep allocating memory for the right cell. + if (hasEqualLeftAndRightValues()) { + // Setting this to null so the GC release the memory allocated to the right cell. + this.rightValueCell = null; + } + } + + public void selectLeftValue() { + toggleGroup.selectToggle(leftValueCell); + } + + public void selectRightValue() { + if (isRightValueCellHidden()) { + selectLeftValue(); + } else { + toggleGroup.selectToggle(rightValueCell); + } + } + + public String getMergedValue() { + return mergedValueProperty().getValue(); + } + + public ReadOnlyStringProperty mergedValueProperty() { + return mergedValueCell.textProperty(); + } + + public FieldNameCell getFieldNameCell() { + return fieldNameCell; + } + + public FieldValueCell getLeftValueCell() { + return leftValueCell; + } + + public FieldValueCell getRightValueCell() { + return rightValueCell; + } + + public MergedFieldCell getMergedValueCell() { + return mergedValueCell; + } + + public boolean hasEqualLeftAndRightValues() { + return isRightValueCellHidden() || (!StringUtil.isNullOrEmpty(leftValueCell.getText()) && + !StringUtil.isNullOrEmpty(rightValueCell.getText()) && + leftValueCell.getText().equals(rightValueCell.getText())); + } + + public void showDiff(ShowDiffConfig diffConfig) { + if (isRightValueCellHidden()) { + return; + } + + 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 (isRightValueCellHidden()) { + return; + } + + int leftValueLength = getLeftValueCell().getStyleClassedLabel().getLength(); + getLeftValueCell().getStyleClassedLabel().clearStyle(0, leftValueLength); + getLeftValueCell().getStyleClassedLabel().replaceText(leftValue); + + int rightValueLength = getRightValueCell().getStyleClassedLabel().getLength(); + getRightValueCell().getStyleClassedLabel().clearStyle(0, rightValueLength); + getRightValueCell().getStyleClassedLabel().replaceText(rightValue); + } + + private boolean isRightValueCellHidden() { + return rightValueCell == null; + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ShowDiffConfig.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ShowDiffConfig.java new file mode 100644 index 00000000000..62697e3119b --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ShowDiffConfig.java @@ -0,0 +1,9 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import static org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter.DiffMethod; +import static org.jabref.gui.mergeentries.newmergedialog.toolbar.ThreeWayMergeToolbar.DiffView; + +public record ShowDiffConfig( + DiffView diffView, + DiffMethod diffHighlightingMethod) { +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeHeaderView.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeHeaderView.java new file mode 100644 index 00000000000..bf896f4b8a5 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeHeaderView.java @@ -0,0 +1,52 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import javafx.geometry.Insets; +import javafx.scene.control.Control; +import javafx.scene.layout.GridPane; + +import org.jabref.gui.mergeentries.newmergedialog.cell.HeaderCell; +import org.jabref.logic.l10n.Localization; + +/** + * GridPane was used instead of a Hbox because Hbox allocates more space for cells + * with longer text, but I wanted all cells to have the same width + */ +public class ThreeWayMergeHeaderView extends GridPane { + public static final String DEFAULT_STYLE_CLASS = "merge-header"; + private final HeaderCell leftHeaderCell; + private final HeaderCell rightHeaderCell; + + private final HeaderCell mergedHeaderCell; + + public ThreeWayMergeHeaderView(String leftHeader, String rightHeader) { + getStyleClass().add(DEFAULT_STYLE_CLASS); + + this.leftHeaderCell = new HeaderCell(leftHeader); + this.rightHeaderCell = new HeaderCell(rightHeader); + this.mergedHeaderCell = new HeaderCell(Localization.lang("Merged Entry")); + + addRow(0, + new HeaderCell(""), + leftHeaderCell, + rightHeaderCell, + mergedHeaderCell + ); + + setPrefHeight(Control.USE_COMPUTED_SIZE); + setMaxHeight(Control.USE_PREF_SIZE); + setMinHeight(Control.USE_PREF_SIZE); + + // The fields grid pane is contained within a scroll pane, thus it doesn't allocate the full available width. In + // fact, it uses the available width minus the size of the scrollbar which is 8. This leads to header columns being + // always wider than fields columns. This hack should fix it. + setPadding(new Insets(0, 8, 0, 0)); + } + + public void setLeftHeader(String leftHeader) { + leftHeaderCell.setText(leftHeader); + } + + public void setRightHeader(String rightHeader) { + rightHeaderCell.setText(rightHeader); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css new file mode 100644 index 00000000000..416b55b308a --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.css @@ -0,0 +1,45 @@ +.merged-field .styled-text-area { + -fx-background-color: -fx-outer-border, -fx-control-inner-background; + -fx-background-insets: 0, 1; + -fx-prompt-text-fill: -fx-mid-text-color; +} +.merged-field .styled-text-area:focused { + -fx-highlight-fill: derive(-jr-accent, 20%); + -fx-background-color: -jr-accent, -fx-control-inner-background; + -fx-background-insets: 0, 2; + -fx-highlight-text-fill: -fx-text-inner-color; +} + +.merge-field-value:selected .selection-box { + -fx-background-color: -jr-accent-alt; + -fx-border-color: -jr-accent; +} + +.merge-field-value .selection-box { + -fx-background-color: #0000; + -fx-border-color: #0000; + -fx-border-radius: 8; + -fx-background-radius: 8; + -fx-border-width: 2.5; +} + +.styled-text-area .text{ + -fx-fill: -fx-text-background-color; +} + +.field-cell:odd { + -fx-background-color: -jr-row-odd-background; +} + +.field-cell:even { + -fx-background-color: -jr-row-even-background; +} + +.merge-toolbox { + -fx-background-color: -jr-menu-background; +} + +.merge-header-cell .label{ + -fx-font-weight: bold; + -fx-padding: 1, 0, 1, 0; +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java new file mode 100644 index 00000000000..7ab42c83d2f --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeView.java @@ -0,0 +1,171 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import java.util.ArrayList; +import java.util.List; + +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Screen; + +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"); + + private final ColumnConstraints fieldNameColumnConstraints = new ColumnConstraints(150); + private final ColumnConstraints leftEntryColumnConstraints = new ColumnConstraints(GRID_COLUMN_MIN_WIDTH, 256, Double.MAX_VALUE); + private final ColumnConstraints rightEntryColumnConstraints = new ColumnConstraints(GRID_COLUMN_MIN_WIDTH, 256, Double.MAX_VALUE); + private final ColumnConstraints mergedEntryColumnConstraints = new ColumnConstraints(GRID_COLUMN_MIN_WIDTH, 256, Double.MAX_VALUE); + private final ThreeWayMergeToolbar toolbar; + private final ThreeWayMergeHeaderView headerView; + private final ScrollPane scrollPane; + private final GridPane mergeGridPane; + + private final ThreeWayMergeViewModel viewModel; + private final List fieldRowControllerList = new ArrayList<>(); + + 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); + + mergeGridPane = new GridPane(); + scrollPane = new ScrollPane(); + headerView = new ThreeWayMergeHeaderView(leftHeader, rightHeader); + toolbar = new ThreeWayMergeToolbar(); + + initializeColumnConstraints(); + initializeMergeGridPane(); + initializeScrollPane(); + initializeHeaderView(); + initializeToolbar(); + + this.setPrefHeight(Screen.getPrimary().getBounds().getHeight() * 0.76); + this.setPrefWidth(Screen.getPrimary().getBounds().getWidth() * 0.97); + + getChildren().addAll(toolbar, headerView, scrollPane); + } + + public ThreeWayMergeView(BibEntry leftEntry, BibEntry rightEntry) { + this(leftEntry, rightEntry, LEFT_DEFAULT_HEADER, RIGHT_DEFAULT_HEADER); + } + + private void initializeToolbar() { + toolbar.setOnSelectLeftEntryValuesButtonClicked(this::selectLeftEntryValues); + toolbar.setOnSelectRightEntryValuesButtonClicked(this::selectRightEntryValues); + + toolbar.showDiffProperty().addListener(e -> updateDiff()); + toolbar.diffViewProperty().addListener(e -> updateDiff()); + toolbar.diffHighlightingMethodProperty().addListener(e -> updateDiff()); + } + + private void updateDiff() { + if (toolbar.isShowDiffEnabled()) { + fieldRowControllerList.forEach(fieldRow -> fieldRow.showDiff(new ShowDiffConfig(toolbar.getDiffView(), toolbar.getDiffHighlightingMethod()))); + } else { + fieldRowControllerList.forEach(FieldRowController::hideDiff); + } + } + + private void initializeHeaderView() { + headerView.getColumnConstraints().addAll(fieldNameColumnConstraints, + leftEntryColumnConstraints, + rightEntryColumnConstraints, + mergedEntryColumnConstraints); + } + + private void initializeScrollPane() { + scrollPane.setFitToWidth(true); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + scrollPane.setContent(mergeGridPane); + } + + private void initializeColumnConstraints() { + fieldNameColumnConstraints.setHgrow(Priority.NEVER); + leftEntryColumnConstraints.setHgrow(Priority.ALWAYS); + rightEntryColumnConstraints.setHgrow(Priority.ALWAYS); + mergedEntryColumnConstraints.setHgrow(Priority.ALWAYS); + } + + private void initializeMergeGridPane() { + mergeGridPane.getColumnConstraints().addAll(fieldNameColumnConstraints, leftEntryColumnConstraints, rightEntryColumnConstraints, mergedEntryColumnConstraints); + + for (int fieldIndex = 0; fieldIndex < viewModel.allFieldsSize(); fieldIndex++) { + addFieldRow(fieldIndex); + } + } + + private void addFieldRow(int index) { + Field field = 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(""); + } + + 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()); + } + + 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()); + } + } + + public BibEntry getMergedEntry() { + return viewModel.getMergedEntry(); + } + + public void setLeftHeader(String leftHeader) { + headerView.setLeftHeader(leftHeader); + } + + public void setRightHeader(String rightHeader) { + headerView.setRightHeader(rightHeader); + } + + public void selectLeftEntryValues() { + fieldRowControllerList.forEach(FieldRowController::selectLeftValue); + } + + public void selectRightEntryValues() { + fieldRowControllerList.forEach(FieldRowController::selectRightValue); + } + + public void showDiff(ShowDiffConfig diffConfig) { + toolbar.setDiffView(diffConfig.diffView()); + toolbar.setDiffHighlightingMethod(diffConfig.diffHighlightingMethod()); + toolbar.setShowDiff(true); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeViewModel.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeViewModel.java new file mode 100644 index 00000000000..5314a725875 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/ThreeWayMergeViewModel.java @@ -0,0 +1,115 @@ +package org.jabref.gui.mergeentries.newmergedialog; + +import java.util.Comparator; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import org.jabref.gui.AbstractViewModel; +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.InternalField; + +class ThreeWayMergeViewModel extends AbstractViewModel { + + private final ObjectProperty leftEntry = new SimpleObjectProperty<>(); + private final ObjectProperty rightEntry = new SimpleObjectProperty<>(); + private final ObjectProperty mergedEntry = new SimpleObjectProperty<>(); + private final StringProperty leftHeader = new SimpleStringProperty(); + private final StringProperty rightHeader = new SimpleStringProperty(); + + private final ObservableList allFields = FXCollections.observableArrayList(); + + public ThreeWayMergeViewModel(BibEntry leftEntry, BibEntry rightEntry, String leftHeader, String rightHeader) { + Objects.requireNonNull(leftEntry, "Left entry is required"); + Objects.requireNonNull(rightEntry, "Right entry is required"); + Objects.requireNonNull(leftHeader, "Left header entry is required"); + Objects.requireNonNull(rightHeader, "Right header is required"); + + setLeftEntry(leftEntry); + setRightEntry(rightEntry); + setLeftHeader(leftHeader); + setRightHeader(rightHeader); + + mergedEntry.set(new BibEntry()); + + Set leftAndRightFieldsUnion = new HashSet<>(leftEntry.getFields()); + leftAndRightFieldsUnion.addAll(rightEntry.getFields()); + setAllFields(leftAndRightFieldsUnion); + } + + public StringProperty leftHeaderProperty() { + return leftHeader; + } + + public String getLeftHeader() { + return leftHeader.get(); + } + + public void setLeftHeader(String leftHeader) { + leftHeaderProperty().set(leftHeader); + } + + public StringProperty rightHeaderProperty() { + return rightHeader; + } + + public String getRightHeader() { + return rightHeaderProperty().get(); + } + + public void setRightHeader(String rightHeader) { + rightHeaderProperty().set(rightHeader); + } + + public BibEntry getLeftEntry() { + return leftEntry.get(); + } + + private void setLeftEntry(BibEntry bibEntry) { + leftEntry.set(bibEntry); + } + + public BibEntry getRightEntry() { + return rightEntry.get(); + } + + private void setRightEntry(BibEntry bibEntry) { + rightEntry.set(bibEntry); + } + + public BibEntry getMergedEntry() { + return mergedEntry.get(); + } + + public ObservableList allFields() { + return allFields; + } + + /** + * Convince method to determine the total number of fields in the union of the left and right fields. + */ + public int allFieldsSize() { + return allFields.size(); + } + + private void setAllFields(Set fields) { + allFields.clear(); + allFields.addAll(fields); + // Don't show internal fields. See org.jabref.model.entry.field.InternalField + allFields.removeIf(FieldFactory::isInternalField); + + allFields.sort(Comparator.comparing(Field::getName)); + + // Add the entry type field as the first field to display + allFields.add(0, InternalField.TYPE_HEADER); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/AbstractCell.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/AbstractCell.java new file mode 100644 index 00000000000..ffb1f536986 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/AbstractCell.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 AbstractCell 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 AbstractCell.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 AbstractCell.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 AbstractCell(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/CopyFieldValueCommand.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/CopyFieldValueCommand.java new file mode 100644 index 00000000000..e18a831933b --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/CopyFieldValueCommand.java @@ -0,0 +1,23 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import java.util.Objects; + +import org.jabref.gui.ClipBoardManager; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.preferences.PreferencesService; + +public class CopyFieldValueCommand extends SimpleCommand { + private final String fieldValue; + private final ClipBoardManager clipBoardManager; + + public CopyFieldValueCommand(PreferencesService preferencesService, final String fieldValue) { + Objects.requireNonNull(fieldValue, "Field value cannot be null"); + this.fieldValue = fieldValue; + this.clipBoardManager = new ClipBoardManager(preferencesService); + } + + @Override + public void execute() { + clipBoardManager.setContent(fieldValue); + } +} 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 new file mode 100644 index 00000000000..7ad435d59b1 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldNameCell.java @@ -0,0 +1,26 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import javafx.scene.control.Label; + +/** + * A non-editable cell that contains the name of some field + */ +public class FieldNameCell extends AbstractCell { + public static final String DEFAULT_STYLE_CLASS = "field-name"; + private final Label label = new Label(); + + public FieldNameCell(String text, int rowIndex) { + super(text, rowIndex); + initialize(); + } + + private void initialize() { + getStyleClass().add(DEFAULT_STYLE_CLASS); + initializeLabel(); + getChildren().add(label); + } + + private void initializeLabel() { + label.textProperty().bind(textProperty()); + } +} 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 new file mode 100644 index 00000000000..f31639ccc25 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/FieldValueCell.java @@ -0,0 +1,224 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.BooleanPropertyBase; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Toggle; +import javafx.scene.control.ToggleGroup; +import javafx.scene.input.MouseEvent; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.Background; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.paint.Color; + +import org.jabref.gui.Globals; +import org.jabref.gui.actions.ActionFactory; +import org.jabref.gui.fieldeditors.URLUtil; +import org.jabref.gui.icon.IconTheme; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.identifier.DOI; +import org.jabref.model.strings.StringUtil; + +import com.tobiasdiez.easybind.EasyBind; +import org.fxmisc.flowless.VirtualizedScrollPane; +import org.fxmisc.richtext.StyleClassedTextArea; +import org.kordamp.ikonli.javafx.FontIcon; +import org.kordamp.ikonli.materialdesign2.MaterialDesignC; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A readonly, selectable field cell that contains the value of some field + */ +public class FieldValueCell extends AbstractCell implements Toggle { + public static final Logger LOGGER = LoggerFactory.getLogger(FieldValueCell.class); + + public static final String DEFAULT_STYLE_CLASS = "merge-field-value"; + public static final String SELECTION_BOX_STYLE_CLASS = "selection-box"; + + private static final PseudoClass SELECTED_PSEUDO_CLASS = PseudoClass.getPseudoClass("selected"); + private final ObjectProperty toggleGroup = new SimpleObjectProperty<>(); + private final StyleClassedTextArea label = new StyleClassedTextArea(); + + private final ActionFactory factory = new ActionFactory(Globals.getKeyPrefs()); + + private final VirtualizedScrollPane scrollPane = new VirtualizedScrollPane<>(label); + HBox labelBox = new HBox(scrollPane); + private final BooleanProperty selected = new BooleanPropertyBase() { + @Override + public Object getBean() { + return FieldValueCell.class; + } + + @Override + public String getName() { + return "selected"; + } + + @Override + protected void invalidated() { + pseudoClassStateChanged(SELECTED_PSEUDO_CLASS, get()); + + getToggleGroup().selectToggle(FieldValueCell.this); + } + }; + private final HBox selectionBox = new HBox(); + private final HBox actionsContainer = new HBox(); + + public FieldValueCell(String text, int rowIndex) { + super(text, rowIndex); + initialize(); + } + + private void initialize() { + getStyleClass().add(DEFAULT_STYLE_CLASS); + initializeScrollPane(); + initializeLabel(); + initializeSelectionBox(); + initializeActions(); + textProperty().addListener(invalidated -> setUserData(getText())); + setOnMouseClicked(e -> { + if (!isDisabled()) { + setSelected(true); + } + }); + + selectionBox.getChildren().addAll(labelBox, actionsContainer); + getChildren().setAll(selectionBox); + } + + private void initializeLabel() { + label.setEditable(false); + label.setBackground(Background.fill(Color.TRANSPARENT)); + label.appendText(textProperty().get()); + label.setAutoHeight(true); + label.setWrapText(true); + label.setStyle("-fx-cursor: hand"); + + // Workarounds + preventTextSelectionViaMouseEvents(); + + label.prefHeightProperty().bind(label.totalHeightEstimateProperty().orElseConst(-1d)); + + // Fix text area consuming scroll events before they reach the outer scrollable + label.addEventFilter(ScrollEvent.SCROLL, e -> { + e.consume(); + FieldValueCell.this.fireEvent(e.copyFor(e.getSource(), FieldValueCell.this)); + }); + } + + private void initializeActions() { + actionsContainer.getChildren().setAll(createOpenLinkButton(), createCopyButton()); + actionsContainer.setAlignment(Pos.TOP_CENTER); + actionsContainer.setPrefWidth(28); + } + + private void initializeSelectionBox() { + selectionBox.getStyleClass().add(SELECTION_BOX_STYLE_CLASS); + HBox.setHgrow(selectionBox, Priority.ALWAYS); + + HBox.setHgrow(labelBox, Priority.ALWAYS); + labelBox.setPadding(new Insets(8)); + labelBox.setCursor(Cursor.HAND); + } + + private Button createCopyButton() { + FontIcon copyIcon = FontIcon.of(MaterialDesignC.CONTENT_COPY); + copyIcon.getStyleClass().add("action-icon"); + + Button copyButton = factory.createIconButton(() -> Localization.lang("Copy"), new CopyFieldValueCommand(Globals.prefs, getText())); + copyButton.setGraphic(copyIcon); + copyButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + copyButton.setMaxHeight(Double.MAX_VALUE); + copyButton.visibleProperty().bind(textProperty().isEmpty().not()); + + return copyButton; + } + + public Button createOpenLinkButton() { + Node openLinkIcon = IconTheme.JabRefIcons.OPEN_LINK.getGraphicNode(); + openLinkIcon.getStyleClass().add("action-icon"); + + Button openLinkButton = factory.createIconButton(() -> Localization.lang("Open Link"), new OpenExternalLinkAction(getText())); + openLinkButton.setGraphic(openLinkIcon); + openLinkButton.setContentDisplay(ContentDisplay.GRAPHIC_ONLY); + openLinkButton.setMaxHeight(Double.MAX_VALUE); + + openLinkButton.visibleProperty().bind(EasyBind.map(textProperty(), input -> StringUtil.isNotBlank(input) && (URLUtil.isURL(input) || DOI.isValid(input)))); + + return openLinkButton; + } + + private void initializeScrollPane() { + HBox.setHgrow(scrollPane, Priority.ALWAYS); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + } + + private void preventTextSelectionViaMouseEvents() { + label.addEventFilter(MouseEvent.ANY, e -> { + if (e.getEventType() == MouseEvent.MOUSE_DRAGGED || + e.getEventType() == MouseEvent.DRAG_DETECTED || + e.getEventType() == MouseEvent.MOUSE_ENTERED) { + e.consume(); + } else if (e.getEventType() == MouseEvent.MOUSE_PRESSED) { + if (e.getClickCount() > 1) { + e.consume(); + } + } + }); + } + + @Override + public ToggleGroup getToggleGroup() { + return toggleGroupProperty().get(); + } + + @Override + public void setToggleGroup(ToggleGroup toggleGroup) { + toggleGroupProperty().set(toggleGroup); + } + + @Override + public ObjectProperty toggleGroupProperty() { + return toggleGroup; + } + + @Override + public boolean isSelected() { + return selectedProperty().get(); + } + + @Override + public void setSelected(boolean selected) { + selectedProperty().set(selected); + } + + @Override + public BooleanProperty selectedProperty() { + return selected; + } + + @Override + public void setUserData(Object value) { + super.setText((String) value); + } + + @Override + public Object getUserData() { + return super.getText(); + } + + public StyleClassedTextArea getStyleClassedLabel() { + return label; + } +} 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 new file mode 100644 index 00000000000..2fd82afd38b --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/HeaderCell.java @@ -0,0 +1,25 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import javafx.geometry.Insets; +import javafx.scene.control.Label; + +public class HeaderCell extends AbstractCell { + 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); + initialize(); + } + + private void initialize() { + getStyleClass().add(DEFAULT_STYLE_CLASS); + initializeLabel(); + getChildren().add(label); + } + + private void initializeLabel() { + label.textProperty().bind(textProperty()); + label.setPadding(new Insets(getPadding().getTop(), getPadding().getRight(), getPadding().getBottom(), 16)); + } +} 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 new file mode 100644 index 00000000000..77f170541d6 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/MergedFieldCell.java @@ -0,0 +1,46 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.input.ScrollEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +import org.jabref.gui.util.BindingsHelper; + +import org.fxmisc.richtext.StyleClassedTextArea; + +public class MergedFieldCell extends AbstractCell { + private static final String DEFAULT_STYLE_CLASS = "merged-field"; + + private final StyleClassedTextArea textArea = new StyleClassedTextArea(); + + public MergedFieldCell(String text, int rowIndex) { + super(text, rowIndex); + initialize(); + } + + private void initialize() { + getStyleClass().add(DEFAULT_STYLE_CLASS); + initializeTextArea(); + getChildren().add(textArea); + } + + private void initializeTextArea() { + BindingsHelper.bindBidirectional(textArea.textProperty(), + textProperty(), + textArea::replaceText, + textProperty()::setValue); + + setAlignment(Pos.CENTER); + textArea.setWrapText(true); + textArea.setAutoHeight(true); + textArea.setPadding(new Insets(8)); + HBox.setHgrow(textArea, Priority.ALWAYS); + + textArea.addEventFilter(ScrollEvent.SCROLL, e -> { + e.consume(); + MergedFieldCell.this.fireEvent(e.copyFor(e.getSource(), MergedFieldCell.this)); + }); + } +} 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 new file mode 100644 index 00000000000..102b192458a --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/cell/OpenExternalLinkAction.java @@ -0,0 +1,41 @@ +package org.jabref.gui.mergeentries.newmergedialog.cell; + +import java.io.IOException; +import java.net.URI; + +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.desktop.JabRefDesktop; +import org.jabref.model.entry.identifier.DOI; +import org.jabref.model.strings.StringUtil; + +/** + * This action can open an Url and DOI + * */ +public class OpenExternalLinkAction extends SimpleCommand { + private final String urlOrDoi; + + public OpenExternalLinkAction(String urlOrDoi) { + this.urlOrDoi = urlOrDoi; + } + + @Override + public void execute() { + if (StringUtil.isBlank(urlOrDoi)) { + return; + } + + try { + String url; + if (DOI.isValid(urlOrDoi)) { + url = DOI.parse(urlOrDoi).flatMap(DOI::getExternalURI).map(URI::toString).orElse(""); + } else { + url = urlOrDoi; + } + + JabRefDesktop.openBrowser(url); + } catch ( + IOException ex) { + // TODO: Do something + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/DiffHighlighter.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/DiffHighlighter.java new file mode 100644 index 00000000000..17149e368d6 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/DiffHighlighter.java @@ -0,0 +1,69 @@ +package org.jabref.gui.mergeentries.newmergedialog.diffhighlighter; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +import org.fxmisc.richtext.StyleClassedTextArea; + +public abstract sealed class DiffHighlighter permits SplitDiffHighlighter, UnifiedDiffHighlighter { + protected final StyleClassedTextArea sourceTextview; + protected final StyleClassedTextArea targetTextview; + + protected DiffMethod diffMethod; + + public DiffHighlighter(StyleClassedTextArea sourceTextview, StyleClassedTextArea targetTextview, DiffMethod diffMethod) { + Objects.requireNonNull(sourceTextview, "source text view MUST NOT be null."); + Objects.requireNonNull(targetTextview, "target text view MUST NOT be null."); + + this.sourceTextview = sourceTextview; + this.targetTextview = targetTextview; + this.diffMethod = diffMethod; + } + + abstract void highlight(); + + protected List splitString(String str) { + return Arrays.asList(str.split(diffMethod.separator())); + } + + private void setDiffMethod(DiffMethod diffMethod) { + this.diffMethod = diffMethod; + } + + public DiffMethod getDiffMethod() { + return diffMethod; + } + + public String getSeparator() { + return diffMethod.separator(); + } + + public enum DiffMethod { + WORDS(" "), CHARS(""); + + private final String separator; + + DiffMethod(String separator) { + this.separator = separator; + } + + public String separator() { + return separator; + } + } + + protected String join(List stringList) { + return String.join(getSeparator(), stringList); + } + + enum ChangeType { + ADDITION, DELETION, CHANGE_DELETION + } + + record Change( + int position, + int spanSize, + ChangeType type) { + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/SplitDiffHighlighter.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/SplitDiffHighlighter.java new file mode 100644 index 00000000000..14570f6b2a8 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/SplitDiffHighlighter.java @@ -0,0 +1,74 @@ +package org.jabref.gui.mergeentries.newmergedialog.diffhighlighter; + +import java.util.List; + +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import org.fxmisc.richtext.StyleClassedTextArea; + +/** + * A diff highlighter in which changes are split between source and target text view. + * They are represented by an addition in the target text view and deletion in the source text view. + */ +public final class SplitDiffHighlighter extends DiffHighlighter { + + public SplitDiffHighlighter(StyleClassedTextArea sourceTextview, StyleClassedTextArea targetTextview, DiffMethod diffMethod) { + super(sourceTextview, targetTextview, diffMethod); + } + + @Override + public void highlight() { + String sourceContent = sourceTextview.getText(); + String targetContent = targetTextview.getText(); + if (sourceContent.equals(targetContent)) { + return; + } + + List sourceTokens = splitString(sourceContent); + List targetTokens = splitString(targetContent); + + List> deltaList = DiffUtils.diff(sourceTokens, targetTokens).getDeltas(); + + for (AbstractDelta delta : deltaList) { + int affectedSourceTokensPosition = delta.getSource().getPosition(); + int affectedTargetTokensPosition = delta.getTarget().getPosition(); + + List affectedTokensInSource = delta.getSource().getLines(); + List affectedTokensInTarget = delta.getTarget().getLines(); + int joinedSourceTokensLength = affectedTokensInSource.stream() + .map(String::length) + .reduce(Integer::sum) + .map(value -> value + (getSeparator().length() * (affectedTokensInSource.size() - 1))) + .orElse(0); + + int joinedTargetTokensLength = affectedTokensInTarget.stream() + .map(String::length) + .reduce(Integer::sum) + .map(value -> value + (getSeparator().length() * (affectedTokensInTarget.size() - 1))) + .orElse(0); + int affectedSourceTokensPositionInText = getPositionInText(affectedSourceTokensPosition, sourceTokens); + int affectedTargetTokensPositionInText = getPositionInText(affectedTargetTokensPosition, targetTokens); + switch (delta.getType()) { + case CHANGE -> { + sourceTextview.setStyleClass(affectedSourceTokensPositionInText, affectedSourceTokensPositionInText + joinedSourceTokensLength, "deletion"); + targetTextview.setStyleClass(affectedTargetTokensPositionInText, affectedTargetTokensPositionInText + joinedTargetTokensLength, "updated"); + } + case DELETE -> + sourceTextview.setStyleClass(affectedSourceTokensPositionInText, affectedSourceTokensPositionInText + joinedSourceTokensLength, "deletion"); + case INSERT -> + targetTextview.setStyleClass(affectedTargetTokensPositionInText, affectedTargetTokensPositionInText + joinedTargetTokensLength, "addition"); + } + } + } + + public int getPositionInText(int positionInTokenList, List tokenList) { + if (positionInTokenList == 0) { + return 0; + } else { + return tokenList.stream().limit(positionInTokenList).map(String::length) + .reduce(Integer::sum) + .map(value -> value + (getSeparator().length() * positionInTokenList)) + .orElse(0); + } + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/UnifiedDiffHighlighter.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/UnifiedDiffHighlighter.java new file mode 100644 index 00000000000..54824f06d61 --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/diffhighlighter/UnifiedDiffHighlighter.java @@ -0,0 +1,117 @@ +package org.jabref.gui.mergeentries.newmergedialog.diffhighlighter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.DeltaType; +import org.fxmisc.richtext.StyleClassedTextArea; + +/** + * A diff highlighter in which differences of type {@link DeltaType#CHANGE} are unified and represented by an insertion + * and deletion in the target text view. Normal addition and deletion are kept as they are. + */ +public final class UnifiedDiffHighlighter extends DiffHighlighter { + + public UnifiedDiffHighlighter(StyleClassedTextArea sourceTextview, StyleClassedTextArea targetTextview, DiffMethod diffMethod) { + super(sourceTextview, targetTextview, diffMethod); + } + + @Override + public void highlight() { + String sourceContent = sourceTextview.getText(); + String targetContent = targetTextview.getText(); + if (sourceContent.equals(targetContent)) { + return; + } + + List sourceWords = splitString(sourceContent); + List targetWords = splitString(targetContent); + List unifiedWords = new ArrayList<>(targetWords); + + List> deltaList = DiffUtils.diff(sourceWords, targetWords).getDeltas(); + + List changeList = new ArrayList<>(); + + int deletionCount = 0; + for (AbstractDelta delta : deltaList) { + switch (delta.getType()) { + case CHANGE -> { + int changePosition = delta.getTarget().getPosition(); + int deletionPoint = changePosition + deletionCount; + int insertionPoint = deletionPoint + 1; + List deltaSourceWords = delta.getSource().getLines(); + List deltaTargetWords = delta.getTarget().getLines(); + + unifiedWords.add(deletionPoint, join(deltaSourceWords)); + + changeList.add(new Change(deletionPoint, 1, ChangeType.CHANGE_DELETION)); + changeList.add(new Change(insertionPoint, deltaTargetWords.size(), ChangeType.ADDITION)); + deletionCount++; + } + case DELETE -> { + int deletionPoint = delta.getTarget().getPosition() + deletionCount; + unifiedWords.add(deletionPoint, join(delta.getSource().getLines())); + + changeList.add(new Change(deletionPoint, 1, ChangeType.DELETION)); + deletionCount++; + } + case INSERT -> { + int insertionPoint = delta.getTarget().getPosition() + deletionCount; + changeList.add(new Change(insertionPoint, delta.getTarget().getLines().size(), ChangeType.ADDITION)); + } + } + } + targetTextview.clear(); + + boolean changeInProgress = false; + for (int position = 0; position < unifiedWords.size(); position++) { + String word = unifiedWords.get(position); + Optional changeAtPosition = findChange(position, changeList); + if (changeAtPosition.isEmpty()) { + appendToTextArea(targetTextview, getSeparator() + word, "unchanged"); + } else { + Change change = changeAtPosition.get(); + List changeWords = unifiedWords.subList(change.position(), change.position() + change.spanSize()); + + if (change.type() == ChangeType.DELETION) { + appendToTextArea(targetTextview, getSeparator() + join(changeWords), "deletion"); + } else if (change.type() == ChangeType.ADDITION) { + if (changeInProgress) { + appendToTextArea(targetTextview, join(changeWords), "addition"); + changeInProgress = false; + } else { + appendToTextArea(targetTextview, getSeparator() + join(changeWords), "addition"); + } + } else if (change.type() == ChangeType.CHANGE_DELETION) { + appendToTextArea(targetTextview, getSeparator() + join(changeWords), "deletion"); + changeInProgress = true; + } + position = position + changeWords.size() - 1; + } + } + if (targetTextview.getLength() >= getSeparator().length()) { + // There always going to be an extra separator at the start + targetTextview.deleteText(0, getSeparator().length()); + } + } + + private void appendToTextArea(StyleClassedTextArea textArea, String text, String styleClass) { + if (text.isEmpty()) { + return; + } + // Append separator without styling it + if (text.startsWith(getSeparator())) { + textArea.append(getSeparator(), "unchanged"); + textArea.append(text.substring(getSeparator().length()), styleClass); + } else { + textArea.append(text, styleClass); + } + } + + private Optional findChange(int position, List changeList) { + return changeList.stream().filter(change -> change.position() == position).findAny(); + } +} diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.fxml b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.fxml new file mode 100644 index 00000000000..59fde5d824b --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.fxml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.java b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.java new file mode 100644 index 00000000000..08189a7647b --- /dev/null +++ b/src/main/java/org/jabref/gui/mergeentries/newmergedialog/toolbar/ThreeWayMergeToolbar.java @@ -0,0 +1,200 @@ +package org.jabref.gui.mergeentries.newmergedialog.toolbar; + +import java.util.Arrays; + +import javafx.beans.binding.BooleanExpression; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.RadioButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.AnchorPane; +import javafx.util.StringConverter; + +import org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.views.ViewLoader; +import com.tobiasdiez.easybind.EasyBind; +import com.tobiasdiez.easybind.EasyBinding; + +import static org.jabref.gui.mergeentries.newmergedialog.diffhighlighter.DiffHighlighter.DiffMethod; + +public class ThreeWayMergeToolbar extends AnchorPane { + @FXML + private RadioButton highlightCharactersRadioButtons; + + @FXML + private RadioButton highlightWordsRadioButton; + + @FXML + private ToggleGroup diffHighlightingMethodToggleGroup; + + @FXML + private ComboBox diffViewComboBox; + + @FXML + private ComboBox plainTextOrDiffComboBox; + + @FXML + private Button selectLeftEntryValuesButton; + + @FXML + private Button selectRightEntryValuesButton; + + private final ObjectProperty diffHighlightingMethod = new SimpleObjectProperty<>(); + private EasyBinding showDiff; + + public ThreeWayMergeToolbar() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + public void initialize() { + showDiff = EasyBind.map(plainTextOrDiffComboBox.valueProperty(), plainTextOrDiff -> plainTextOrDiff == PlainTextOrDiff.Diff); + + plainTextOrDiffComboBox.getItems().addAll(PlainTextOrDiff.values()); + plainTextOrDiffComboBox.getSelectionModel().select(PlainTextOrDiff.PLAIN_TEXT); + plainTextOrDiffComboBox.setConverter(new StringConverter<>() { + @Override + public String toString(PlainTextOrDiff plainTextOrDiff) { + return plainTextOrDiff.getValue(); + } + + @Override + public PlainTextOrDiff fromString(String string) { + return PlainTextOrDiff.fromString(string); + } + }); + + diffViewComboBox.disableProperty().bind(notShowDiffProperty()); + diffViewComboBox.getItems().addAll(DiffView.values()); + diffViewComboBox.getSelectionModel().select(DiffView.UNIFIED); + diffViewComboBox.setConverter(new StringConverter<>() { + @Override + public String toString(DiffView diffView) { + return diffView.getValue(); + } + + @Override + public DiffView fromString(String string) { + return DiffView.fromString(string); + } + }); + + highlightWordsRadioButton.disableProperty().bind(notShowDiffProperty()); + highlightCharactersRadioButtons.disableProperty().bind(notShowDiffProperty()); + + diffHighlightingMethodToggleGroup.selectedToggleProperty().addListener((observable -> { + if (diffHighlightingMethodToggleGroup.getSelectedToggle().equals(highlightCharactersRadioButtons)) { + diffHighlightingMethod.set(DiffMethod.CHARS); + } else { + diffHighlightingMethod.set(DiffMethod.WORDS); + } + })); + + diffHighlightingMethodToggleGroup.selectToggle(highlightWordsRadioButton); + } + + public ObjectProperty diffViewProperty() { + return diffViewComboBox.valueProperty(); + } + + public DiffView getDiffView() { + return diffViewProperty().get(); + } + + public void setDiffView(DiffView diffView) { + diffViewProperty().set(diffView); + } + + public EasyBinding showDiffProperty() { + return showDiff; + } + + public void setShowDiff(boolean showDiff) { + plainTextOrDiffComboBox.valueProperty().set(showDiff ? PlainTextOrDiff.Diff : PlainTextOrDiff.PLAIN_TEXT); + } + + /** + * Convenience method used to disable diff related views when diff is not selected. + * + *

+ * This method is required because {@link EasyBinding} class doesn't have a method to invert a boolean property, + * like {@link BooleanExpression#not()} + *

+ */ + public EasyBinding notShowDiffProperty() { + return showDiffProperty().map(showDiff -> !showDiff); + } + + public Boolean isShowDiffEnabled() { + return showDiffProperty().get(); + } + + public ObjectProperty diffHighlightingMethodProperty() { + return diffHighlightingMethod; + } + + public DiffMethod getDiffHighlightingMethod() { + return diffHighlightingMethodProperty().get(); + } + + public void setDiffHighlightingMethod(DiffMethod diffHighlightingMethod) { + diffHighlightingMethodProperty().set(diffHighlightingMethod); + } + + public void setOnSelectLeftEntryValuesButtonClicked(Runnable onClick) { + selectLeftEntryValuesButton.setOnMouseClicked(e -> onClick.run()); + } + + public void setOnSelectRightEntryValuesButtonClicked(Runnable onClick) { + selectRightEntryValuesButton.setOnMouseClicked(e -> onClick.run()); + } + + public enum PlainTextOrDiff { + PLAIN_TEXT(Localization.lang("Plain Text")), Diff(Localization.lang("Show Diff")); + + private final String value; + + PlainTextOrDiff(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static PlainTextOrDiff fromString(String str) { + return Arrays.stream(values()) + .filter(plainTextOrDiff -> plainTextOrDiff.getValue().equals(str)) + .findAny() + .orElseThrow(IllegalArgumentException::new); + } + } + + public enum DiffView { + UNIFIED(Localization.lang("Unified View")), + SPLIT(Localization.lang("Split View")); + private final String value; + + DiffView(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public static DiffView fromString(String str) { + return Arrays.stream(values()) + .filter(diffView -> diffView.getValue().equals(str)) + .findAny() + .orElseThrow(IllegalArgumentException::new); + } + } +} diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index dc9d31a3e5b..d65f8370995 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -323,7 +323,6 @@ Entry\ preview=Entry preview Entry\ table=Entry table Entry\ table\ columns=Entry table columns Entry\ Title\ (Required\ to\ deliver\ recommendations.)=Entry Title (Required to deliver recommendations.) -Entry\ type=Entry type Error=Error Error\ occurred\ when\ parsing\ entry=Error occurred when parsing entry Error\ opening\ file=Error opening file @@ -1311,8 +1310,8 @@ Help\ on\ Name\ Formatting=Help on Name Formatting Add\ new\ file\ type=Add new file type -Left\ entry=Left entry -Right\ entry=Right entry +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. @@ -1557,11 +1556,7 @@ Verse=Verse change\ entries\ of\ group=change entries of group odd\ number\ of\ unescaped\ '\#'=odd number of unescaped '#' -Plain\ text=Plain text Show\ diff=Show diff -character=character -word=word -Show\ symmetric\ diff=Show symmetric diff Copy\ Version=Copy Version Maintainers=Maintainers Contributors=Contributors @@ -2086,8 +2081,6 @@ Linked\ identifiers=Linked identifiers insert\ entries=insert entries In\ JabRef=In JabRef On\ disk=On disk -Select\ all\ changes\ on\ the\ left=Select all changes on the left -Select\ all\ changes\ on\ the\ right=Select all changes on the right Dismiss=Dismiss Mark\ all\ changes\ as\ accepted=Mark all changes as accepted Unmark\ all\ changes=Unmark all changes @@ -2512,6 +2505,15 @@ Swap\ content=Swap content Copy\ or\ move\ the\ content\ of\ one\ field\ to\ another=Copy or move the content of one field to another Automatic\ field\ editor=Automatic field editor +Open\ Link=Open Link +Highlight\ words=Highlight words +Highlight\ characters=Highlight characters +Unified\ View=Unified View +Split\ View=Split View +Plain\ Text=Plain Text +Show\ Diff=Show Diff +Merged\ Entry=Merged Entry + (Note\:\ If\ original\ entries\ lack\ keywords\ to\ qualify\ for\ the\ new\ group\ configuration,\ confirming\ here\ will\ add\ them)=(Note: If original entries lack keywords to qualify for the new group configuration, confirming here will add them) Assign=Assign Do\ not\ assign=Do not assign diff --git a/src/test/java/org/jabref/logic/net/URLUtilTest.java b/src/test/java/org/jabref/logic/net/URLUtilTest.java index 04ed0e99a94..d69d7a1df7e 100644 --- a/src/test/java/org/jabref/logic/net/URLUtilTest.java +++ b/src/test/java/org/jabref/logic/net/URLUtilTest.java @@ -73,4 +73,9 @@ void isURLshouldRejectInvalidURL() { assertFalse(URLUtil.isURL("www.google.com")); assertFalse(URLUtil.isURL("google.com")); } + + @Test + void isURLshouldRejectEmbeddedURL() { + assertFalse(URLUtil.isURL("dblp computer science bibliography, http://dblp.org")); + } }