From f0709b71ca0a303d17904e63072ef4abef29d539 Mon Sep 17 00:00:00 2001 From: Dominik Voigt Date: Sun, 4 Jul 2021 21:02:18 +0200 Subject: [PATCH] Feature add git workflow for slr search (#7625) * Add study UI and Git support * Modify localization * Fix checkstyle * Fix test issues * Fix architecture test, modify some formulations in UI * Add DI for services * Get table views working standardize UI * Clean up * Make localization consistent * Compressed fxml and fixed minor visual issue (table cell too small) * Reduced code duplication Co-authored-by: Carl Christian Snethlage Co-authored-by: Siedlerchr --- CHANGELOG.md | 1 + src/main/java/org/jabref/gui/JabRefFrame.java | 14 +- .../gui/StartLiteratureReviewAction.java | 90 ------- .../jabref/gui/actions/StandardActions.java | 3 +- .../ImportCustomEntryTypesDialog.fxml | 28 +- .../gui/slr/ExistingStudySearchAction.java | 127 +++++++++ .../jabref/gui/slr/ManageStudyDefinition.css | 3 + .../jabref/gui/slr/ManageStudyDefinition.fxml | 201 +++++++++++++++ .../gui/slr/ManageStudyDefinitionView.java | 242 ++++++++++++++++++ .../slr/ManageStudyDefinitionViewModel.java | 182 +++++++++++++ .../jabref/gui/slr/SlrStudyAndDirectory.java | 23 ++ .../jabref/gui/slr/StartNewStudyAction.java | 41 +++ .../org/jabref/gui/slr/StudyDatabaseItem.java | 64 +++++ .../org/jabref/logic/crawler/Crawler.java | 19 +- .../jabref/logic/crawler/StudyRepository.java | 156 +++++++++-- .../jabref/logic/crawler/git/GitHandler.java | 83 ------ .../java/org/jabref/logic/git/GitHandler.java | 200 +++++++++++++++ .../org/jabref/logic/git/SlrGitHandler.java | 155 +++++++++++ .../java/org/jabref/model/study/Study.java | 25 ++ src/main/resources/l10n/JabRef_en.properties | 31 ++- .../org/jabref/logic/git/git.gitignore | 4 + .../org/jabref/logic/crawler/CrawlerTest.java | 18 +- .../StudyDatabaseToFetcherConverterTest.java | 6 +- .../logic/crawler/StudyRepositoryTest.java | 6 +- .../org/jabref/logic/git/GitHandlerTest.java | 58 +++++ .../jabref/logic/git/SlrGitHandlerTest.java | 83 ++++++ 26 files changed, 1612 insertions(+), 251 deletions(-) delete mode 100644 src/main/java/org/jabref/gui/StartLiteratureReviewAction.java create mode 100644 src/main/java/org/jabref/gui/slr/ExistingStudySearchAction.java create mode 100644 src/main/java/org/jabref/gui/slr/ManageStudyDefinition.css create mode 100644 src/main/java/org/jabref/gui/slr/ManageStudyDefinition.fxml create mode 100644 src/main/java/org/jabref/gui/slr/ManageStudyDefinitionView.java create mode 100644 src/main/java/org/jabref/gui/slr/ManageStudyDefinitionViewModel.java create mode 100644 src/main/java/org/jabref/gui/slr/SlrStudyAndDirectory.java create mode 100644 src/main/java/org/jabref/gui/slr/StartNewStudyAction.java create mode 100644 src/main/java/org/jabref/gui/slr/StudyDatabaseItem.java delete mode 100644 src/main/java/org/jabref/logic/crawler/git/GitHandler.java create mode 100644 src/main/java/org/jabref/logic/git/GitHandler.java create mode 100644 src/main/java/org/jabref/logic/git/SlrGitHandler.java create mode 100644 src/main/resources/org/jabref/logic/git/git.gitignore create mode 100644 src/test/java/org/jabref/logic/git/GitHandlerTest.java create mode 100644 src/test/java/org/jabref/logic/git/SlrGitHandlerTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 306129f377c..407111ee185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Note that this project **does not** adhere to [Semantic Versioning](http://semve - We added two new fields to track the creation and most recent modification date and time for each entry. [koppor#130](https://github.com/koppor/jabref/issues/130) - We added a feature that allows the user to copy highlighted text in the preview window. [#6962](https://github.com/JabRef/jabref/issues/6962) - We added a feature that allows you to create new BibEntry via paste arxivId [#2292](https://github.com/JabRef/jabref/issues/2292) +- We added support for conducting automated and systematic literature search across libraries and git support for persistence [#369](https://github.com/koppor/jabref/issues/369) - We added a add group functionality at the bottom of the side pane. [#4682](https://github.com/JabRef/jabref/issues/4682) - We added a feature that allows the user to choose whether to trust the target site when unable to find a valid certification path from the file download site. [#7616](https://github.com/JabRef/jabref/issues/7616) - We added a feature that allows the user to open all linked files of multiple selected entries by "Open file" option. [#6966](https://github.com/JabRef/jabref/issues/6966) diff --git a/src/main/java/org/jabref/gui/JabRefFrame.java b/src/main/java/org/jabref/gui/JabRefFrame.java index 2cc4c2f81d9..817fd6263bb 100644 --- a/src/main/java/org/jabref/gui/JabRefFrame.java +++ b/src/main/java/org/jabref/gui/JabRefFrame.java @@ -108,6 +108,8 @@ import org.jabref.gui.search.GlobalSearchBar; import org.jabref.gui.shared.ConnectToSharedDatabaseCommand; import org.jabref.gui.shared.PullChangesFromSharedAction; +import org.jabref.gui.slr.ExistingStudySearchAction; +import org.jabref.gui.slr.StartNewStudyAction; import org.jabref.gui.specialfields.SpecialFieldMenuItemFactory; import org.jabref.gui.texparser.ParseLatexAction; import org.jabref.gui.undo.CountingUndoManager; @@ -813,13 +815,11 @@ private MenuBar createMenu() { new SeparatorMenuItem(), - factory.createMenuItem(StandardActions.SEND_AS_EMAIL, new SendAsEMailAction(dialogService, prefs, stateManager)), - pushToApplicationMenuItem - // Disabled until PR #7126 can be merged - // new SeparatorMenuItem(), - // factory.createMenuItem(StandardActions.START_SYSTEMATIC_LITERATURE_REVIEW, - // new StartLiteratureReviewAction(this, Globals.getFileUpdateMonitor(), prefs.getWorkingDir(), - // taskExecutor, prefs, prefs.getImportFormatPreferences(), prefs.getSavePreferences())) + factory.createMenuItem(StandardActions.SEND_AS_EMAIL, new SendAsEMailAction(dialogService, this.prefs, stateManager)), + pushToApplicationMenuItem, + new SeparatorMenuItem(), + factory.createMenuItem(StandardActions.START_NEW_STUDY, new StartNewStudyAction(this, Globals.getFileUpdateMonitor(), Globals.TASK_EXECUTOR, prefs)), + factory.createMenuItem(StandardActions.SEARCH_FOR_EXISTING_STUDY, new ExistingStudySearchAction(this, Globals.getFileUpdateMonitor(), Globals.TASK_EXECUTOR, prefs)) ); SidePaneComponent webSearch = sidePaneManager.getComponent(SidePaneType.WEB_SEARCH); diff --git a/src/main/java/org/jabref/gui/StartLiteratureReviewAction.java b/src/main/java/org/jabref/gui/StartLiteratureReviewAction.java deleted file mode 100644 index ef2cf84a69f..00000000000 --- a/src/main/java/org/jabref/gui/StartLiteratureReviewAction.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.jabref.gui; - -import java.io.IOException; -import java.nio.file.Path; -import java.util.Optional; - -import org.jabref.gui.actions.SimpleCommand; -import org.jabref.gui.importer.actions.OpenDatabaseAction; -import org.jabref.gui.util.BackgroundTask; -import org.jabref.gui.util.FileDialogConfiguration; -import org.jabref.gui.util.TaskExecutor; -import org.jabref.logic.crawler.Crawler; -import org.jabref.logic.crawler.git.GitHandler; -import org.jabref.logic.exporter.SavePreferences; -import org.jabref.logic.importer.ImportFormatPreferences; -import org.jabref.logic.importer.ParseException; -import org.jabref.logic.l10n.Localization; -import org.jabref.model.entry.BibEntryTypesManager; -import org.jabref.model.util.FileUpdateMonitor; -import org.jabref.preferences.PreferencesService; - -import org.eclipse.jgit.api.errors.GitAPIException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class StartLiteratureReviewAction extends SimpleCommand { - private static final Logger LOGGER = LoggerFactory.getLogger(StartLiteratureReviewAction.class); - private final JabRefFrame frame; - private final DialogService dialogService; - private final FileUpdateMonitor fileUpdateMonitor; - private final Path workingDirectory; - private final TaskExecutor taskExecutor; - private final PreferencesService preferencesService; - private final ImportFormatPreferences importFormatPreferneces; - private final SavePreferences savePreferences; - - public StartLiteratureReviewAction(JabRefFrame frame, FileUpdateMonitor fileUpdateMonitor, Path standardWorkingDirectory, TaskExecutor taskExecutor, PreferencesService preferencesService, ImportFormatPreferences importFormatPreferences, SavePreferences savePreferences) { - this.frame = frame; - this.dialogService = frame.getDialogService(); - this.fileUpdateMonitor = fileUpdateMonitor; - this.workingDirectory = getInitialDirectory(standardWorkingDirectory); - this.taskExecutor = taskExecutor; - this.preferencesService = preferencesService; - this.importFormatPreferneces = importFormatPreferences; - this.savePreferences = savePreferences; - } - - @Override - public void execute() { - FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() - .withInitialDirectory(workingDirectory) - .build(); - - Optional studyDefinitionFile = dialogService.showFileOpenDialog(fileDialogConfiguration); - if (studyDefinitionFile.isEmpty()) { - // Do nothing if selection was canceled - return; - } - final Crawler crawler; - try { - crawler = new Crawler(studyDefinitionFile.get(), new GitHandler(studyDefinitionFile.get().getParent()), fileUpdateMonitor, importFormatPreferneces, savePreferences, preferencesService.getTimestampPreferences(), new BibEntryTypesManager()); - } catch (IOException | ParseException | GitAPIException e) { - LOGGER.error("Error during reading of study definition file.", e); - dialogService.showErrorDialogAndWait(Localization.lang("Error during reading of study definition file."), e); - return; - } - BackgroundTask.wrap(() -> { - crawler.performCrawl(); - return 0; // Return any value to make this a callable instead of a runnable. This allows throwing exceptions. - }) - .onFailure(e -> { - LOGGER.error("Error during persistence of crawling results."); - dialogService.showErrorDialogAndWait(Localization.lang("Error during persistence of crawling results."), e); - }) - .onSuccess(unused -> new OpenDatabaseAction(frame, preferencesService, dialogService).openFile(Path.of(studyDefinitionFile.get().getParent().toString(), "studyResult.bib"), true)) - .executeWith(taskExecutor); - } - - /** - * @return Path of current panel database directory or the standard working directory - */ - private Path getInitialDirectory(Path standardWorkingDirectory) { - if (frame.getBasePanelCount() == 0) { - return standardWorkingDirectory; - } else { - Optional databasePath = frame.getCurrentLibraryTab().getBibDatabaseContext().getDatabasePath(); - return databasePath.map(Path::getParent).orElse(standardWorkingDirectory); - } - } -} diff --git a/src/main/java/org/jabref/gui/actions/StandardActions.java b/src/main/java/org/jabref/gui/actions/StandardActions.java index 59d4be73162..b01b7dfdb59 100644 --- a/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -88,7 +88,8 @@ public enum StandardActions implements Action { PARSE_LATEX(Localization.lang("Search for citations in LaTeX files..."), IconTheme.JabRefIcons.LATEX_CITATIONS), NEW_SUB_LIBRARY_FROM_AUX(Localization.lang("New sublibrary based on AUX file") + "...", Localization.lang("New BibTeX sublibrary") + Localization.lang("This feature generates a new library based on which entries are needed in an existing LaTeX document."), IconTheme.JabRefIcons.NEW), WRITE_XMP(Localization.lang("Write XMP metadata to PDFs"), Localization.lang("Will write XMP metadata to the PDFs linked from selected entries."), KeyBinding.WRITE_XMP), - START_SYSTEMATIC_LITERATURE_REVIEW(Localization.lang("Start systematic literature review")), + START_NEW_STUDY(Localization.lang("Start new systematic literature review")), + SEARCH_FOR_EXISTING_STUDY(Localization.lang("Perform search for existing systematic literature review")), OPEN_DATABASE_FOLDER(Localization.lang("Reveal in file explorer")), OPEN_FOLDER(Localization.lang("Open folder"), Localization.lang("Open folder"), IconTheme.JabRefIcons.FOLDER, KeyBinding.OPEN_FOLDER), OPEN_FILE(Localization.lang("Open file"), Localization.lang("Open file"), IconTheme.JabRefIcons.FILE, KeyBinding.OPEN_FILE), diff --git a/src/main/java/org/jabref/gui/importer/ImportCustomEntryTypesDialog.fxml b/src/main/java/org/jabref/gui/importer/ImportCustomEntryTypesDialog.fxml index 295d8d89a74..37a5a1ba3db 100644 --- a/src/main/java/org/jabref/gui/importer/ImportCustomEntryTypesDialog.fxml +++ b/src/main/java/org/jabref/gui/importer/ImportCustomEntryTypesDialog.fxml @@ -5,26 +5,20 @@ - - - + - - - - + + diff --git a/src/main/java/org/jabref/gui/slr/ExistingStudySearchAction.java b/src/main/java/org/jabref/gui/slr/ExistingStudySearchAction.java new file mode 100644 index 00000000000..d8d1abc6b26 --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/ExistingStudySearchAction.java @@ -0,0 +1,127 @@ +package org.jabref.gui.slr; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.Optional; + +import org.jabref.gui.DialogService; +import org.jabref.gui.JabRefFrame; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.importer.actions.OpenDatabaseAction; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.DirectoryDialogConfiguration; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.logic.crawler.Crawler; +import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.ParseException; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.util.FileUpdateMonitor; +import org.jabref.preferences.PreferencesService; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ExistingStudySearchAction extends SimpleCommand { + private static final Logger LOGGER = LoggerFactory.getLogger(ExistingStudySearchAction.class); + + protected final DialogService dialogService; + protected final Path workingDirectory; + + Path studyDirectory; + + private final JabRefFrame frame; + private final FileUpdateMonitor fileUpdateMonitor; + private final TaskExecutor taskExecutor; + private final PreferencesService preferencesService; + private final ImportFormatPreferences importFormatPreferneces; + private final SavePreferences savePreferences; + // This can be either populated before crawl is called or is populated in the call using the directory dialog. This is helpful if the directory is selected in a previous dialog/UI element + + public ExistingStudySearchAction(JabRefFrame frame, FileUpdateMonitor fileUpdateMonitor, TaskExecutor taskExecutor, PreferencesService preferencesService) { + this.frame = frame; + this.dialogService = frame.getDialogService(); + this.fileUpdateMonitor = fileUpdateMonitor; + this.workingDirectory = getInitialDirectory(preferencesService.getWorkingDir()); + this.taskExecutor = taskExecutor; + this.preferencesService = preferencesService; + this.importFormatPreferneces = preferencesService.getImportFormatPreferences(); + this.savePreferences = preferencesService.getSavePreferences(); + } + + @Override + public void execute() { + // Reset before each execution + studyDirectory = null; + crawl(); + } + + public void crawl() { + if (Objects.isNull(studyDirectory)) { + DirectoryDialogConfiguration directoryDialogConfiguration = new DirectoryDialogConfiguration.Builder() + .withInitialDirectory(workingDirectory) + .build(); + + Optional studyRepositoryRoot = dialogService.showDirectorySelectionDialog(directoryDialogConfiguration); + + if (studyRepositoryRoot.isEmpty()) { + // Do nothing if selection was canceled + return; + } + studyDirectory = studyRepositoryRoot.get(); + } + + try { + setupRepository(studyDirectory); + } catch (IOException | GitAPIException e) { + dialogService.showErrorDialogAndWait(Localization.lang("Study repository could not be created"), e); + return; + } + final Crawler crawler; + try { + crawler = new Crawler(studyDirectory, new SlrGitHandler(studyDirectory), importFormatPreferneces, savePreferences, preferencesService.getTimestampPreferences(), new BibEntryTypesManager(), fileUpdateMonitor); + } catch (IOException | ParseException e) { + LOGGER.error("Error during reading of study definition file.", e); + dialogService.showErrorDialogAndWait(Localization.lang("Error during reading of study definition file."), e); + return; + } + dialogService.notify(Localization.lang("Searching")); + BackgroundTask.wrap(() -> { + crawler.performCrawl(); + return 0; // Return any value to make this a callable instead of a runnable. This allows throwing exceptions. + }) + .onFailure(e -> { + LOGGER.error("Error during persistence of crawling results."); + dialogService.showErrorDialogAndWait(Localization.lang("Error during persistence of crawling results."), e); + }) + .onSuccess(unused -> { + new OpenDatabaseAction(frame, preferencesService, dialogService).openFile(Path.of(studyDirectory.toString(), "studyResult.bib"), true); + // If finished reset command object for next use + studyDirectory = null; + }) + .executeWith(taskExecutor); + } + + /** + * Hook for setting up the repository + */ + protected void setupRepository(Path studyRepositoryRoot) throws IOException, GitAPIException { + // Do nothing as repository is already setup + } + + /** + * @return Path of current panel database directory or the standard working directory + */ + private Path getInitialDirectory(Path standardWorkingDirectory) { + if (frame.getBasePanelCount() == 0) { + return standardWorkingDirectory; + } else { + Optional databasePath = frame.getCurrentLibraryTab().getBibDatabaseContext().getDatabasePath(); + return databasePath.map(Path::getParent).orElse(standardWorkingDirectory); + } + } +} diff --git a/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.css b/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.css new file mode 100644 index 00000000000..e4f5540d22e --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.css @@ -0,0 +1,3 @@ +.slr-tab { + -fx-padding: 1em; +} diff --git a/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.fxml b/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.fxml new file mode 100644 index 00000000000..b6ea1d41f7a --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/ManageStudyDefinition.fxml @@ -0,0 +1,201 @@ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + +
diff --git a/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionView.java b/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionView.java new file mode 100644 index 00000000000..a7b784d06fc --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionView.java @@ -0,0 +1,242 @@ +package org.jabref.gui.slr; + +import java.nio.file.Path; +import java.util.Objects; +import java.util.StringJoiner; +import java.util.function.Consumer; + +import javax.inject.Inject; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.SimpleStringProperty; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonType; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.control.cell.CheckBoxTableCell; +import javafx.scene.control.cell.TextFieldTableCell; +import javafx.scene.input.KeyCode; + +import org.jabref.gui.DialogService; +import org.jabref.gui.icon.IconTheme; +import org.jabref.gui.util.BaseDialog; +import org.jabref.gui.util.DirectoryDialogConfiguration; +import org.jabref.gui.util.ValueTableCellFactory; +import org.jabref.gui.util.ViewModelListCellFactory; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.study.Study; +import org.jabref.preferences.PreferencesService; + +import com.airhacks.afterburner.views.ViewLoader; + +/** + * This class controls the user interface of the study definition management dialog. The UI elements and their layout + * are defined in the FXML file. + */ +public class ManageStudyDefinitionView extends BaseDialog { + Path workingDirectory; + + @Inject DialogService dialogService; + @Inject PreferencesService prefs; + + private ManageStudyDefinitionViewModel viewModel; + private final Study study; + + @FXML private TextField studyTitle; + @FXML private TextField addAuthor; + @FXML private TextField addResearchQuestion; + @FXML private TextField addQuery; + @FXML private ComboBox databaseSelector; + @FXML private TextField studyDirectory; + + @FXML private ButtonType saveButtonType; + @FXML private Label helpIcon; + + @FXML private TableView authorTableView; + @FXML private TableColumn authorsColumn; + @FXML private TableColumn authorsActionColumn; + + @FXML private TableView questionTableView; + @FXML private TableColumn questionsColumn; + @FXML private TableColumn questionsActionColumn; + + @FXML private TableView queryTableView; + @FXML private TableColumn queriesColumn; + @FXML private TableColumn queriesActionColumn; + + @FXML private TableView databaseTable; + @FXML private TableColumn databaseEnabledColumn; + @FXML private TableColumn databaseColumn; + @FXML private TableColumn databaseActionColumn; + + /** + * This can be used to either create new study objects or edit existing ones. + * + * @param study null if a new study is created. Otherwise the study object to edit. + * @param studyDirectory the directory where the study to edit is located (null if a new study is created) + */ + public ManageStudyDefinitionView(Study study, Path studyDirectory, Path workingDirectory) { + // If an existing study is edited, open the directory dialog at the directory the study is stored + this.workingDirectory = Objects.isNull(studyDirectory) ? workingDirectory : studyDirectory; + this.setTitle(Objects.isNull(studyDirectory) ? Localization.lang("Define study parameters") : Localization.lang("Manage study definition")); + this.study = study; + + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + + setupSaveButton(); + } + + private void setupSaveButton() { + Button saveButton = ((Button) this.getDialogPane().lookupButton(saveButtonType)); + + saveButton.disableProperty().bind(Bindings.or(Bindings.or( + Bindings.or( + Bindings.or(Bindings.isEmpty(viewModel.getQueries()), Bindings.isEmpty(viewModel.getDatabases())), + Bindings.isEmpty(viewModel.getAuthors())), + viewModel.getTitle().isEmpty()), viewModel.getDirectory().isEmpty())); + + setResultConverter(button -> { + if (button == saveButtonType) { + return viewModel.saveStudy(); + } + // Cancel button will return null + return null; + }); + } + + @FXML + private void initialize() { + viewModel = new ManageStudyDefinitionViewModel(study, workingDirectory, prefs.getImportFormatPreferences()); + + // Listen whether any databases are removed from selection -> Add back to the database selector + studyTitle.textProperty().bindBidirectional(viewModel.titleProperty()); + studyDirectory.textProperty().bindBidirectional(viewModel.getDirectory()); + + initAuthorTab(); + initQuestionsTab(); + initQueriesTab(); + initDatabasesTab(); + } + + private void initAuthorTab() { + setupCommonPropertiesForTables(addAuthor, this::addAuthor, authorsColumn, authorsActionColumn); + setupCellFactories(authorsColumn, authorsActionColumn, viewModel::deleteAuthor); + authorTableView.setItems(viewModel.getAuthors()); + } + + private void initQuestionsTab() { + setupCommonPropertiesForTables(addResearchQuestion, this::addResearchQuestion, questionsColumn, questionsActionColumn); + setupCellFactories(questionsColumn, questionsActionColumn, viewModel::deleteQuestion); + questionTableView.setItems(viewModel.getResearchQuestions()); + } + + private void initQueriesTab() { + setupCommonPropertiesForTables(addQuery, this::addQuery, queriesColumn, queriesActionColumn); + setupCellFactories(queriesColumn, queriesActionColumn, viewModel::deleteQuery); + queryTableView.setItems(viewModel.getQueries()); + + // TODO: Keep until PR #7279 is merged + helpIcon.setTooltip(new Tooltip(new StringJoiner("\n") + .add(Localization.lang("Query terms are separated by spaces.")) + .add(Localization.lang("All query terms are joined using the logical AND, and OR operators") + ".") + .add(Localization.lang("If the sequence of terms is relevant wrap them in double quotes") + "(\").") + .add(Localization.lang("An example:") + " rain AND (clouds OR drops) AND \"precipitation distribution\"") + .toString())); + } + + private void initDatabasesTab() { + new ViewModelListCellFactory().withText(StudyDatabaseItem::getName) + .install(databaseSelector); + databaseSelector.setItems(viewModel.getNonSelectedDatabases()); + + setupCommonPropertiesForTables(databaseSelector, this::addDatabase, databaseColumn, databaseActionColumn); + + databaseEnabledColumn.setResizable(false); + databaseEnabledColumn.setReorderable(false); + databaseEnabledColumn.setCellValueFactory(param -> param.getValue().enabledProperty()); + databaseEnabledColumn.setCellFactory(CheckBoxTableCell.forTableColumn(databaseEnabledColumn)); + + databaseColumn.setCellValueFactory(param -> param.getValue().nameProperty()); + databaseActionColumn.setCellValueFactory(param -> param.getValue().nameProperty()); + new ValueTableCellFactory() + .withGraphic(item -> IconTheme.JabRefIcons.DELETE_ENTRY.getGraphicNode()) + .withTooltip(name -> Localization.lang("Remove")) + .withOnMouseClickedEvent(item -> evt -> + viewModel.removeDatabase(item)) + .install(databaseActionColumn); + + databaseTable.setItems(viewModel.getDatabases()); + } + + private void setupCommonPropertiesForTables(Node addControl, + Runnable addAction, + TableColumn contentColumn, + TableColumn actionColumn) { + addControl.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER) { + addAction.run(); + } + }); + + contentColumn.setReorderable(false); + contentColumn.setCellFactory(TextFieldTableCell.forTableColumn()); + actionColumn.setReorderable(false); + actionColumn.setResizable(false); + } + + private void setupCellFactories(TableColumn contentColumn, + TableColumn actionColumn, + Consumer removeAction) { + contentColumn.setCellValueFactory(param -> new SimpleStringProperty(param.getValue())); + actionColumn.setCellValueFactory(param -> new SimpleStringProperty(param.getValue())); + new ValueTableCellFactory() + .withGraphic(item -> IconTheme.JabRefIcons.DELETE_ENTRY.getGraphicNode()) + .withTooltip(name -> Localization.lang("Remove")) + .withOnMouseClickedEvent(item -> evt -> + removeAction.accept(item)) + .install(actionColumn); + } + + @FXML + private void addAuthor() { + viewModel.addAuthor(addAuthor.getText()); + addAuthor.setText(""); + } + + @FXML + private void addResearchQuestion() { + viewModel.addResearchQuestion(addResearchQuestion.getText()); + addResearchQuestion.setText(""); + } + + @FXML + private void addQuery() { + viewModel.addQuery(addQuery.getText()); + addQuery.setText(""); + } + + /** + * Add selected entry from combobox, push onto database pop from nonselecteddatabase (combobox) + */ + @FXML + private void addDatabase() { + viewModel.addDatabase(databaseSelector.getSelectionModel().getSelectedItem()); + } + + @FXML + public void selectStudyDirectory() { + DirectoryDialogConfiguration directoryDialogConfiguration = new DirectoryDialogConfiguration.Builder() + .withInitialDirectory(workingDirectory) + .build(); + + viewModel.setStudyDirectory(dialogService.showDirectorySelectionDialog(directoryDialogConfiguration)); + } +} diff --git a/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionViewModel.java b/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionViewModel.java new file mode 100644 index 00000000000..c7f47a0ca56 --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/ManageStudyDefinitionViewModel.java @@ -0,0 +1,182 @@ +package org.jabref.gui.slr; + +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import javafx.beans.property.Property; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import org.jabref.logic.importer.ImportFormatPreferences; +import org.jabref.logic.importer.SearchBasedFetcher; +import org.jabref.logic.importer.WebFetchers; +import org.jabref.model.study.Study; +import org.jabref.model.study.StudyDatabase; +import org.jabref.model.study.StudyQuery; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class provides a model for managing study definitions. + * To visualize the model one can bind the properties to UI elements. + */ +public class ManageStudyDefinitionViewModel { + private static final Logger LOGGER = LoggerFactory.getLogger(ManageStudyDefinitionViewModel.class); + + private final StringProperty title = new SimpleStringProperty(); + private final ObservableList authors = FXCollections.observableArrayList(); + private final ObservableList researchQuestions = FXCollections.observableArrayList(); + private final ObservableList queries = FXCollections.observableArrayList(); + private final ObservableList databases = FXCollections.observableArrayList(); + // Hold the complement of databases for the selector + private final ObservableList nonSelectedDatabases = FXCollections.observableArrayList(); + private final SimpleStringProperty directory = new SimpleStringProperty(); + private Study study; + + public ManageStudyDefinitionViewModel(Study study, Path studyDirectory, ImportFormatPreferences importFormatPreferences) { + if (Objects.isNull(study)) { + computeNonSelectedDatabases(importFormatPreferences); + return; + } + this.study = study; + title.setValue(study.getTitle()); + authors.addAll(study.getAuthors()); + researchQuestions.addAll(study.getResearchQuestions()); + queries.addAll(study.getQueries().stream().map(StudyQuery::getQuery).collect(Collectors.toList())); + databases.addAll(study.getDatabases() + .stream() + .map(studyDatabase -> new StudyDatabaseItem(studyDatabase.getName(), studyDatabase.isEnabled())) + .collect(Collectors.toList())); + computeNonSelectedDatabases(importFormatPreferences); + if (!Objects.isNull(studyDirectory)) { + this.directory.set(studyDirectory.toString()); + } + } + + private void computeNonSelectedDatabases(ImportFormatPreferences importFormatPreferences) { + nonSelectedDatabases.addAll(WebFetchers.getSearchBasedFetchers(importFormatPreferences) + .stream() + .map(SearchBasedFetcher::getName) + .map(s -> new StudyDatabaseItem(s, true)) + .filter(studyDatabase -> !databases.contains(studyDatabase)) + .collect(Collectors.toList())); + } + + public StringProperty getTitle() { + return title; + } + + public StringProperty getDirectory() { + return directory; + } + + public ObservableList getAuthors() { + return authors; + } + + public ObservableList getResearchQuestions() { + return researchQuestions; + } + + public ObservableList getQueries() { + return queries; + } + + public ObservableList getDatabases() { + return databases; + } + + public ObservableList getNonSelectedDatabases() { + return nonSelectedDatabases; + } + + public void addAuthor(String author) { + if (author.isBlank()) { + return; + } + authors.add(author); + } + + public void addResearchQuestion(String researchQuestion) { + if (researchQuestion.isBlank() || researchQuestions.contains(researchQuestion)) { + return; + } + researchQuestions.add(researchQuestion); + } + + public void addQuery(String query) { + if (query.isBlank()) { + return; + } + queries.add(query); + } + + public void addDatabase(StudyDatabaseItem database) { + if (Objects.isNull(database)) { + return; + } + nonSelectedDatabases.remove(database); + if (!databases.contains(database)) { + databases.add(database); + } + } + + public SlrStudyAndDirectory saveStudy() { + if (Objects.isNull(study)) { + study = new Study(); + } + study.setTitle(title.getValueSafe()); + study.setAuthors(authors); + study.setResearchQuestions(researchQuestions); + study.setQueries(queries.stream().map(StudyQuery::new).collect(Collectors.toList())); + study.setDatabases(databases.stream().map(studyDatabaseItem -> new StudyDatabase(studyDatabaseItem.getName(), studyDatabaseItem.isEnabled())).collect(Collectors.toList())); + Path studyDirectory = null; + try { + studyDirectory = Path.of(directory.getValueSafe()); + } catch (InvalidPathException e) { + LOGGER.error("Invalid path was provided: {}", directory); + } + return new SlrStudyAndDirectory(study, studyDirectory); + } + + public Property titleProperty() { + return title; + } + + public void removeDatabase(String database) { + // If a database is added from the combo box it should be enabled by default + Optional correspondingDatabase = databases.stream().filter(studyDatabaseItem -> studyDatabaseItem.getName().equals(database)).findFirst(); + if (correspondingDatabase.isEmpty()) { + return; + } + StudyDatabaseItem databaseToRemove = correspondingDatabase.get(); + databases.remove(databaseToRemove); + databaseToRemove.setEnabled(true); + nonSelectedDatabases.add(databaseToRemove); + // Resort list + nonSelectedDatabases.sort(Comparator.comparing(StudyDatabaseItem::getName)); + } + + public void setStudyDirectory(Optional studyRepositoryRoot) { + getDirectory().setValue(studyRepositoryRoot.isPresent() ? studyRepositoryRoot.get().toString() : getDirectory().getValueSafe()); + } + + public void deleteAuthor(String item) { + authors.remove(item); + } + + public void deleteQuestion(String item) { + researchQuestions.remove(item); + } + + public void deleteQuery(String item) { + queries.remove(item); + } +} diff --git a/src/main/java/org/jabref/gui/slr/SlrStudyAndDirectory.java b/src/main/java/org/jabref/gui/slr/SlrStudyAndDirectory.java new file mode 100644 index 00000000000..2baf1f38791 --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/SlrStudyAndDirectory.java @@ -0,0 +1,23 @@ +package org.jabref.gui.slr; + +import java.nio.file.Path; + +import org.jabref.model.study.Study; + +public class SlrStudyAndDirectory { + private final Study study; + private final Path studyDirectory; + + public SlrStudyAndDirectory(Study study, Path studyDirectory) { + this.study = study; + this.studyDirectory = studyDirectory; + } + + public Path getStudyDirectory() { + return studyDirectory; + } + + public Study getStudy() { + return study; + } +} diff --git a/src/main/java/org/jabref/gui/slr/StartNewStudyAction.java b/src/main/java/org/jabref/gui/slr/StartNewStudyAction.java new file mode 100644 index 00000000000..463db54daaa --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/StartNewStudyAction.java @@ -0,0 +1,41 @@ +package org.jabref.gui.slr; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.gui.JabRefFrame; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.logic.crawler.StudyYamlParser; +import org.jabref.model.study.Study; +import org.jabref.model.util.FileUpdateMonitor; +import org.jabref.preferences.PreferencesService; + +import org.eclipse.jgit.api.errors.GitAPIException; + +public class StartNewStudyAction extends ExistingStudySearchAction { + Study newStudy; + + public StartNewStudyAction(JabRefFrame frame, FileUpdateMonitor fileUpdateMonitor, TaskExecutor taskExecutor, PreferencesService prefs) { + super(frame, fileUpdateMonitor, taskExecutor, prefs); + } + + @Override + protected void setupRepository(Path studyRepositoryRoot) throws IOException, GitAPIException { + StudyYamlParser studyYAMLParser = new StudyYamlParser(); + studyYAMLParser.writeStudyYamlFile(newStudy, studyRepositoryRoot.resolve("study.yml")); + } + + @Override + public void execute() { + Optional studyAndDirectory = dialogService.showCustomDialogAndWait(new ManageStudyDefinitionView(null, null, workingDirectory)); + if (studyAndDirectory.isEmpty()) { + return; + } + if (!studyAndDirectory.get().getStudyDirectory().toString().isBlank()) { + studyDirectory = studyAndDirectory.get().getStudyDirectory(); + } + newStudy = studyAndDirectory.get().getStudy(); + crawl(); + } +} diff --git a/src/main/java/org/jabref/gui/slr/StudyDatabaseItem.java b/src/main/java/org/jabref/gui/slr/StudyDatabaseItem.java new file mode 100644 index 00000000000..50ceda09143 --- /dev/null +++ b/src/main/java/org/jabref/gui/slr/StudyDatabaseItem.java @@ -0,0 +1,64 @@ +package org.jabref.gui.slr; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +public class StudyDatabaseItem { + private final StringProperty name; + private final BooleanProperty enabled; + + public StudyDatabaseItem(String name, boolean enabled) { + this.name = new SimpleStringProperty(name); + this.enabled = new SimpleBooleanProperty(enabled); + } + + public String getName() { + return name.getValue(); + } + + public void setName(String name) { + this.name.setValue(name); + } + + public StringProperty nameProperty() { + return name; + } + + public boolean isEnabled() { + return enabled.getValue(); + } + + public void setEnabled(boolean enabled) { + this.enabled.setValue(enabled); + } + + public BooleanProperty enabledProperty() { + return enabled; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + StudyDatabaseItem that = (StudyDatabaseItem) o; + + if (isEnabled() != that.isEnabled()) { + return false; + } + return getName() != null ? getName().equals(that.getName()) : that.getName() == null; + } + + @Override + public int hashCode() { + int result = getName() != null ? getName().hashCode() : 0; + result = 31 * result + (isEnabled() ? 1 : 0); + return result; + } +} diff --git a/src/main/java/org/jabref/logic/crawler/Crawler.java b/src/main/java/org/jabref/logic/crawler/Crawler.java index 898af4ffbdb..7137bc7b7ff 100644 --- a/src/main/java/org/jabref/logic/crawler/Crawler.java +++ b/src/main/java/org/jabref/logic/crawler/Crawler.java @@ -4,8 +4,9 @@ import java.nio.file.Path; import java.util.List; -import org.jabref.logic.crawler.git.GitHandler; +import org.jabref.logic.exporter.SaveException; import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.ParseException; import org.jabref.logic.preferences.TimestampPreferences; @@ -29,11 +30,9 @@ public class Crawler { /** * Creates a crawler for retrieving studies from E-Libraries * - * @param studyDefinitionFile The path to the study definition file that contains the list of targeted E-Libraries - * and used cross-library queries + * @param studyRepositoryRoot The path to the study repository */ - public Crawler(Path studyDefinitionFile, GitHandler gitHandler, FileUpdateMonitor fileUpdateMonitor, ImportFormatPreferences importFormatPreferences, SavePreferences savePreferences, TimestampPreferences timestampPreferences, BibEntryTypesManager bibEntryTypesManager) throws IllegalArgumentException, IOException, ParseException, GitAPIException { - Path studyRepositoryRoot = studyDefinitionFile.getParent(); + public Crawler(Path studyRepositoryRoot, SlrGitHandler gitHandler, ImportFormatPreferences importFormatPreferences, SavePreferences savePreferences, TimestampPreferences timestampPreferences, BibEntryTypesManager bibEntryTypesManager, FileUpdateMonitor fileUpdateMonitor) throws IllegalArgumentException, IOException, ParseException { studyRepository = new StudyRepository(studyRepositoryRoot, gitHandler, importFormatPreferences, fileUpdateMonitor, savePreferences, timestampPreferences, bibEntryTypesManager); StudyDatabaseToFetcherConverter studyDatabaseToFetcherConverter = new StudyDatabaseToFetcherConverter(studyRepository.getActiveLibraryEntries(), importFormatPreferences); this.studyFetcher = new StudyFetcher(studyDatabaseToFetcherConverter.getActiveFetchers(), studyRepository.getSearchQueryStrings()); @@ -43,9 +42,17 @@ public Crawler(Path studyDefinitionFile, GitHandler gitHandler, FileUpdateMonito * This methods performs the crawling of the active libraries defined in the study definition file. * This method also persists the results in the same folder the study definition file is stored in. * + * The whole process works as follows: + *
    + *
  1. Then the search is executed
  2. + *
  3. The repository changes to the search branch
  4. + *
  5. Afterwards, the results are persisted on the search branch.
  6. + *
  7. Finally, the changes are merged into the work branch
  8. + *
+ * * @throws IOException Thrown if a problem occurred during the persistence of the result. */ - public void performCrawl() throws IOException, GitAPIException { + public void performCrawl() throws IOException, GitAPIException, SaveException { List results = studyFetcher.crawl(); studyRepository.persist(results); } diff --git a/src/main/java/org/jabref/logic/crawler/StudyRepository.java b/src/main/java/org/jabref/logic/crawler/StudyRepository.java index 99aa896cfd5..e36621d0b0d 100644 --- a/src/main/java/org/jabref/logic/crawler/StudyRepository.java +++ b/src/main/java/org/jabref/logic/crawler/StudyRepository.java @@ -3,22 +3,28 @@ import java.io.FileWriter; import java.io.IOException; import java.io.Writer; +import java.nio.charset.UnsupportedCharsetException; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.regex.Pattern; import java.util.stream.Collectors; import org.jabref.logic.citationkeypattern.CitationKeyGenerator; -import org.jabref.logic.crawler.git.GitHandler; import org.jabref.logic.database.DatabaseMerger; +import org.jabref.logic.exporter.AtomicFileWriter; import org.jabref.logic.exporter.BibtexDatabaseWriter; +import org.jabref.logic.exporter.SaveException; import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.OpenDatabase; import org.jabref.logic.importer.ParseException; import org.jabref.logic.importer.SearchBasedFetcher; +import org.jabref.logic.l10n.Localization; import org.jabref.logic.preferences.TimestampPreferences; import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; @@ -42,15 +48,19 @@ * as well as the sharing, and versioning of results using git. */ class StudyRepository { - // Tests work with study.bib + // Tests work with study.yml private static final String STUDY_DEFINITION_FILE_NAME = "study.yml"; private static final Logger LOGGER = LoggerFactory.getLogger(StudyRepository.class); private static final Pattern MATCHCOLON = Pattern.compile(":"); private static final Pattern MATCHILLEGALCHARACTERS = Pattern.compile("[^A-Za-z0-9_.\\s=-]"); + // Currently we make assumptions about the configuration: the remotes, work and search branch names + private static final String REMOTE = "origin"; + private static final String WORK_BRANCH = "work"; + private static final String SEARCH_BRANCH = "search"; private final Path repositoryPath; private final Path studyDefinitionFile; - private final GitHandler gitHandler; + private final SlrGitHandler gitHandler; private final Study study; private final ImportFormatPreferences importFormatPreferences; private final FileUpdateMonitor fileUpdateMonitor; @@ -70,7 +80,7 @@ class StudyRepository { * @throws ParseException Problem parsing the study definition file. */ public StudyRepository(Path pathToRepository, - GitHandler gitHandler, + SlrGitHandler gitHandler, ImportFormatPreferences importFormatPreferences, FileUpdateMonitor fileUpdateMonitor, SavePreferences savePreferences, @@ -78,11 +88,6 @@ public StudyRepository(Path pathToRepository, BibEntryTypesManager bibEntryTypesManager) throws IOException, ParseException { this.repositoryPath = pathToRepository; this.gitHandler = gitHandler; - try { - gitHandler.updateLocalRepository(); - } catch (GitAPIException e) { - LOGGER.error("Updating repository from remote failed"); - } this.importFormatPreferences = importFormatPreferences; this.fileUpdateMonitor = fileUpdateMonitor; this.studyDefinitionFile = Path.of(repositoryPath.toString(), STUDY_DEFINITION_FILE_NAME); @@ -92,40 +97,75 @@ public StudyRepository(Path pathToRepository, if (Files.notExists(repositoryPath)) { throw new IOException("The given repository does not exists."); - } else if (Files.notExists(studyDefinitionFile)) { + } + try { + gitHandler.createCommitOnCurrentBranch("Save changes before searching.", false); + gitHandler.checkoutBranch(WORK_BRANCH); + updateWorkAndSearchBranch(); + } catch (GitAPIException e) { + LOGGER.error("Could not checkout work branch"); + } + if (Files.notExists(studyDefinitionFile)) { throw new IOException("The study definition file does not exist in the given repository."); } study = parseStudyFile(); - this.setUpRepositoryStructure(); + try { + // Update repository structure on work branch in case of changes + setUpRepositoryStructure(); + gitHandler.createCommitOnCurrentBranch("Setup/Update Repository Structure", false); + gitHandler.checkoutBranch(SEARCH_BRANCH); + // If study definition does not exist on this branch or was changed on work branch, copy it from work + boolean studyDefinitionDoesNotExistOrChanged = !(Files.exists(studyDefinitionFile) && new StudyYamlParser().parseStudyYamlFile(studyDefinitionFile).equalsBesideLastSearchDate(study)); + if (studyDefinitionDoesNotExistOrChanged) { + new StudyYamlParser().writeStudyYamlFile(study, studyDefinitionFile); + } + this.setUpRepositoryStructure(); + gitHandler.createCommitOnCurrentBranch("Setup/Update Repository Structure", false); + } catch (GitAPIException e) { + LOGGER.error("Could not checkout search branch."); + } + try { + gitHandler.checkoutBranch(WORK_BRANCH); + } catch (GitAPIException e) { + LOGGER.error("Could not checkout work branch"); + } } /** * Returns entries stored in the repository for a certain query and fetcher */ public BibDatabaseContext getFetcherResultEntries(String query, String fetcherName) throws IOException { - return OpenDatabase.loadDatabase(getPathToFetcherResultFile(query, fetcherName), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + if (Files.exists(getPathToFetcherResultFile(query, fetcherName))) { + return OpenDatabase.loadDatabase(getPathToFetcherResultFile(query, fetcherName), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + } + return new BibDatabaseContext(); } /** * Returns the merged entries stored in the repository for a certain query */ public BibDatabaseContext getQueryResultEntries(String query) throws IOException { - return OpenDatabase.loadDatabase(getPathToQueryResultFile(query), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + if (Files.exists(getPathToQueryResultFile(query))) { + return OpenDatabase.loadDatabase(getPathToQueryResultFile(query), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + } + return new BibDatabaseContext(); } /** * Returns the merged entries stored in the repository for all queries */ public BibDatabaseContext getStudyResultEntries() throws IOException { - return OpenDatabase.loadDatabase(getPathToStudyResultFile(), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + if (Files.exists(getPathToStudyResultFile())) { + return OpenDatabase.loadDatabase(getPathToStudyResultFile(), importFormatPreferences, timestampPreferences, fileUpdateMonitor).getDatabaseContext(); + } + return new BibDatabaseContext(); } /** * The study definition file contains all the definitions of a study. This method extracts this study from the yaml study definition file * * @return Returns the BibEntries parsed from the study definition file. - * @throws IOException Problem opening the input stream. - * @throws ParseException Problem parsing the study definition file. + * @throws IOException Problem opening the input stream. */ private Study parseStudyFile() throws IOException { return new StudyYamlParser().parseStudyYamlFile(studyDefinitionFile); @@ -160,22 +200,70 @@ public Study getStudy() { return study; } - public void persist(List crawlResults) throws IOException, GitAPIException { - try { - gitHandler.updateLocalRepository(); - } catch (GitAPIException e) { - LOGGER.error("Updating repository from remote failed"); - } + /** + * Persists the result locally and remotely by following the steps: + * Precondition: Currently checking out work branch + *
    + *
  1. Update the work and search branch
  2. + *
  3. Persist the results on the search branch
  4. + *
  5. Manually patch the diff of the search branch onto the work branch (as the merging will not work in + * certain cases without a conflict as it is context sensitive. But for this use case we do not need it to be + * context sensitive. So we can just prepend the patch without checking the "context" lines.
  6. + *
  7. Update the remote tracking branches of the work and search branch
  8. + *
+ */ + public void persist(List crawlResults) throws IOException, GitAPIException, SaveException { + updateWorkAndSearchBranch(); + study.setLastSearchDate(LocalDate.now()); + persistStudy(); + gitHandler.createCommitOnCurrentBranch("Update search date", true); + gitHandler.checkoutBranch(SEARCH_BRANCH); persistResults(crawlResults); study.setLastSearchDate(LocalDate.now()); persistStudy(); try { - gitHandler.updateRemoteRepository("Conducted search " + LocalDate.now()); + // First commit changes to search branch branch and update remote + String commitMessage = "Conducted search: " + LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); + boolean newSearchResults = gitHandler.createCommitOnCurrentBranch(commitMessage, false); + gitHandler.checkoutBranch(WORK_BRANCH); + if (!newSearchResults) { + return; + } + // Patch new results into work branch + gitHandler.appendLatestSearchResultsOntoCurrentBranch(commitMessage + " - Patch", SEARCH_BRANCH); + // Update both remote tracked branches + updateRemoteSearchAndWorkBranch(); } catch (GitAPIException e) { - LOGGER.error("Updating remote repository failed"); + LOGGER.error("Updating remote repository failed", e); } } + /** + * Update the remote tracking branches of the work and search branches + * The currently checked out branch is not changed if the method is executed successfully + */ + private void updateRemoteSearchAndWorkBranch() throws IOException, GitAPIException { + String currentBranch = gitHandler.getCurrentlyCheckedOutBranch(); + gitHandler.checkoutBranch(SEARCH_BRANCH); + gitHandler.pushCommitsToRemoteRepository(); + gitHandler.checkoutBranch(WORK_BRANCH); + gitHandler.pushCommitsToRemoteRepository(); + gitHandler.checkoutBranch(currentBranch); + } + + /** + * Updates the local work and search branches with changes from their tracking remote branches + * The currently checked out branch is not changed if the method is executed successfully + */ + private void updateWorkAndSearchBranch() throws IOException, GitAPIException { + String currentBranch = gitHandler.getCurrentlyCheckedOutBranch(); + gitHandler.checkoutBranch(SEARCH_BRANCH); + gitHandler.pullOnCurrentBranch(); + gitHandler.checkoutBranch(WORK_BRANCH); + gitHandler.pullOnCurrentBranch(); + gitHandler.checkoutBranch(currentBranch); + } + private void persistStudy() throws IOException { new StudyYamlParser().writeStudyYamlFile(study, studyDefinitionFile); } @@ -280,7 +368,7 @@ private String computeIDForQuery(String query) { * * @param crawlResults The results that shall be persisted. */ - private void persistResults(List crawlResults) throws IOException { + private void persistResults(List crawlResults) throws IOException, SaveException { DatabaseMerger merger = new DatabaseMerger(importFormatPreferences.getKeywordSeparator()); BibDatabase newStudyResultEntries = new BibDatabase(); @@ -290,11 +378,12 @@ private void persistResults(List crawlResults) throws IOException { BibDatabase fetcherEntries = fetcherResult.getFetchResult(); BibDatabaseContext existingFetcherResult = getFetcherResultEntries(result.getQuery(), fetcherResult.getFetcherName()); + // Merge new entries into fetcher result file + merger.merge(existingFetcherResult.getDatabase(), fetcherEntries); + // Create citation keys for all entries that do not have one generateCiteKeys(existingFetcherResult, fetcherEntries); - // Merge new entries into fetcher result file - merger.merge(existingFetcherResult.getDatabase(), fetcherEntries); // Aggregate each fetcher result into the query result merger.merge(queryResultEntries, fetcherEntries); @@ -322,11 +411,22 @@ private void generateCiteKeys(BibDatabaseContext existingEntries, BibDatabase ta targetEntries.getEntries().stream().filter(bibEntry -> !bibEntry.hasCitationKey()).forEach(citationKeyGenerator::generateAndSetKey); } - private void writeResultToFile(Path pathToFile, BibDatabase entries) throws IOException { + private void writeResultToFile(Path pathToFile, BibDatabase entries) throws IOException, SaveException { + if (!Files.exists(pathToFile)) { + Files.createFile(pathToFile); + } try (Writer fileWriter = new FileWriter(pathToFile.toFile())) { BibtexDatabaseWriter databaseWriter = new BibtexDatabaseWriter(fileWriter, savePreferences, bibEntryTypesManager); databaseWriter.saveDatabase(new BibDatabaseContext(entries)); } + try (AtomicFileWriter fileWriter = new AtomicFileWriter(pathToFile, savePreferences.getEncoding(), savePreferences.shouldMakeBackup())) { + BibtexDatabaseWriter databaseWriter = new BibtexDatabaseWriter(fileWriter, savePreferences, bibEntryTypesManager); + databaseWriter.saveDatabase(new BibDatabaseContext(entries)); + } catch (UnsupportedCharsetException ex) { + throw new SaveException(Localization.lang("Character encoding '%0' is not supported.", savePreferences.getEncoding().displayName()), ex); + } catch (IOException ex) { + throw new SaveException("Problems saving: " + ex, ex); + } } private Path getPathToFetcherResultFile(String query, String fetcherName) { diff --git a/src/main/java/org/jabref/logic/crawler/git/GitHandler.java b/src/main/java/org/jabref/logic/crawler/git/GitHandler.java deleted file mode 100644 index 439f08dfccd..00000000000 --- a/src/main/java/org/jabref/logic/crawler/git/GitHandler.java +++ /dev/null @@ -1,83 +0,0 @@ -package org.jabref.logic.crawler.git; - -import java.io.IOException; -import java.nio.file.Path; - -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.RmCommand; -import org.eclipse.jgit.api.Status; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.transport.CredentialsProvider; -import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * This class handles the updating of the local and remote git repository that is located at the repository path - */ -public class GitHandler { - private static final Logger LOGGER = LoggerFactory.getLogger(GitHandler.class); - private final Path repositoryPath; - private final CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(System.getenv("GIT_EMAIL"), System.getenv("GIT_PW")); - - /** - * Initialize the handler for the given repository - * - * @param repositoryPath The root of the intialized git repository - */ - public GitHandler(Path repositoryPath) { - this.repositoryPath = repositoryPath; - } - - /** - * Updates the local repository based on the main branch of the original remote repository - */ - public void updateLocalRepository() throws IOException, GitAPIException { - try (Git git = Git.open(this.repositoryPath.toFile())) { - git.pull() - .setRemote("origin") - .setRemoteBranchName("main") - .setCredentialsProvider(credentialsProvider) - .call(); - } - } - - /** - * Adds all the added, changed, and removed files to the index and updates the remote origin repository - * If pushiong to remote fails it fails silently - * - * @param commitMessage The commit message used for the commit to the remote repository - */ - public void updateRemoteRepository(String commitMessage) throws IOException, GitAPIException { - // First get up to date - this.updateLocalRepository(); - try (Git git = Git.open(this.repositoryPath.toFile())) { - Status status = git.status().call(); - if (!status.isClean()) { - // Add new and changed files to index - git.add() - .addFilepattern(".") - .call(); - // Add all removed files to index - if (!status.getMissing().isEmpty()) { - RmCommand removeCommand = git.rm() - .setCached(true); - status.getMissing().forEach(removeCommand::addFilepattern); - removeCommand.call(); - } - git.commit() - .setAllowEmpty(false) - .setMessage(commitMessage) - .call(); - try { - - git.push() - .setCredentialsProvider(credentialsProvider) - .call(); - } catch (GitAPIException e) { - LOGGER.info("Failed to push"); - } - } - } - } -} diff --git a/src/main/java/org/jabref/logic/git/GitHandler.java b/src/main/java/org/jabref/logic/git/GitHandler.java new file mode 100644 index 00000000000..f486ae52878 --- /dev/null +++ b/src/main/java/org/jabref/logic/git/GitHandler.java @@ -0,0 +1,200 @@ +package org.jabref.logic.git; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.logic.util.io.FileUtil; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.RmCommand; +import org.eclipse.jgit.api.Status; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.merge.MergeStrategy; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class handles the updating of the local and remote git repository that is located at the repository path + * This provides an easy to use interface to manage a git repository + */ +public class GitHandler { + static final Logger LOGGER = LoggerFactory.getLogger(GitHandler.class); + final Path repositoryPath; + final File repositoryPathAsFile; + String gitUsername = Optional.ofNullable(System.getenv("GIT_EMAIL")).orElse(""); + String gitPassword = Optional.ofNullable(System.getenv("GIT_PW")).orElse(""); + final CredentialsProvider credentialsProvider = new UsernamePasswordCredentialsProvider(gitUsername, gitPassword); + + /** + * Initialize the handler for the given repository + * + * @param repositoryPath The root of the initialized git repository + */ + public GitHandler(Path repositoryPath) { + this.repositoryPath = repositoryPath; + this.repositoryPathAsFile = this.repositoryPath.toFile(); + if (!isGitRepository()) { + try { + Git.init() + .setDirectory(repositoryPathAsFile) + .call(); + try (Git git = Git.open(repositoryPathAsFile)) { + git.commit() + .setAllowEmpty(true) + .setMessage("Initial commit") + .call(); + } + setupGitIgnore(); + } catch (GitAPIException | IOException e) { + LOGGER.error("Initialization failed"); + } + } + } + + void setupGitIgnore() { + try { + Path gitignore = Path.of(repositoryPath.toString(), ".gitignore"); + if (!Files.exists(gitignore)) { + FileUtil.copyFile(Path.of(this.getClass().getResource("git.gitignore").toURI()), gitignore, false); + } + } catch (URISyntaxException e) { + LOGGER.error("Error occurred during copying of the gitignore file into the git repository."); + } + } + + /** + * Returns true if the given path points to a directory that is a git repository (contains a .git folder) + */ + boolean isGitRepository() { + // For some reason the solution from https://www.eclipse.org/lists/jgit-dev/msg01892.html does not work + // This solution is quite simple but might not work in special cases, for us it should suffice. + return Files.exists(Path.of(repositoryPath.toString(), ".git")); + } + + /** + * Checkout the branch with the specified name, if it does not exist create it + * + * @param branchToCheckout Name of the branch to checkout + */ + public void checkoutBranch(String branchToCheckout) throws IOException, GitAPIException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + Optional branch = getRefForBranch(branchToCheckout); + git.checkout() + // If the branch does not exist, create it + .setCreateBranch(branch.isEmpty()) + .setName(branchToCheckout) + .call(); + } + } + + /** + * Returns the reference of the specified branch + * If it does not exist returns an empty optional + */ + Optional getRefForBranch(String branchName) throws GitAPIException, IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + return git.branchList() + .call() + .stream() + .filter(ref -> ref.getName().equals("refs/heads/" + branchName)) + .findAny(); + } + } + + /** + * Creates a commit on the currently checked out branch + * @param amend Whether to amend to the last commit (true), or not (false) + * @return Returns true if a new commit was created. This is the case if the repository was not clean on method invocation + */ + public boolean createCommitOnCurrentBranch(String commitMessage, boolean amend) throws IOException, GitAPIException { + boolean commitCreated = false; + try (Git git = Git.open(this.repositoryPathAsFile)) { + Status status = git.status().call(); + if (!status.isClean()) { + commitCreated = true; + // Add new and changed files to index + git.add() + .addFilepattern(".") + .call(); + // Add all removed files to index + if (!status.getMissing().isEmpty()) { + RmCommand removeCommand = git.rm() + .setCached(true); + status.getMissing().forEach(removeCommand::addFilepattern); + removeCommand.call(); + } + git.commit() + .setAmend(amend) + .setAllowEmpty(false) + .setMessage(commitMessage) + .call(); + } + } + return commitCreated; + } + + /** + * Merges the source branch into the target branch + * + * @param targetBranch the name of the branch that is merged into + * @param sourceBranch the name of the branch that gets merged + */ + public void mergeBranches(String targetBranch, String sourceBranch, MergeStrategy mergeStrategy) throws IOException, GitAPIException { + String currentBranch = this.getCurrentlyCheckedOutBranch(); + try (Git git = Git.open(this.repositoryPathAsFile)) { + Optional sourceBranchRef = getRefForBranch(sourceBranch); + if (sourceBranchRef.isEmpty()) { + // Do nothing + return; + } + this.checkoutBranch(targetBranch); + git.merge() + .include(sourceBranchRef.get()) + .setStrategy(mergeStrategy) + .setMessage("Merge search branch into working branch.") + .call(); + } + this.checkoutBranch(currentBranch); + } + + /** + * Pushes all commits made to the branch that is tracked by the currently checked out branch. + * If pushing to remote fails, it fails silently. + */ + public void pushCommitsToRemoteRepository() throws IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + try { + git.push() + .setCredentialsProvider(credentialsProvider) + .call(); + } catch (GitAPIException e) { + LOGGER.info("Failed to push"); + } + } + } + + public void pullOnCurrentBranch() throws IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + try { + git.pull() + .setCredentialsProvider(credentialsProvider) + .call(); + } catch (GitAPIException e) { + LOGGER.info("Failed to push"); + } + } + } + + public String getCurrentlyCheckedOutBranch() throws IOException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + return git.getRepository().getBranch(); + } + } +} diff --git a/src/main/java/org/jabref/logic/git/SlrGitHandler.java b/src/main/java/org/jabref/logic/git/SlrGitHandler.java new file mode 100644 index 00000000000..d9bb027d220 --- /dev/null +++ b/src/main/java/org/jabref/logic/git/SlrGitHandler.java @@ -0,0 +1,155 @@ +package org.jabref.logic.git; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.StringJoiner; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.diff.DiffEntry; +import org.eclipse.jgit.diff.DiffFormatter; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectReader; +import org.eclipse.jgit.lib.Ref; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; + +public class SlrGitHandler extends GitHandler { + /** + * Initialize the handler for the given repository + * + * @param repositoryPath The root of the initialized git repository + */ + public SlrGitHandler(Path repositoryPath) { + super(repositoryPath); + } + + public void appendLatestSearchResultsOntoCurrentBranch(String patchMessage, String searchBranchName) throws IOException, GitAPIException { + // Calculate and apply new search results to work branch + String patch = calculatePatchOfNewSearchResults(searchBranchName); + Map result = parsePatchForAddedEntries(patch); + + applyPatch(result); + this.createCommitOnCurrentBranch(patchMessage, false); + } + + /** + * Calculates the diff between the HEAD and the previous commit of the sourceBranch. + * + * @param sourceBranch The name of the branch that is the target of the calculation + * @return Returns the patch (diff) between the head of the sourceBranch and its previous commit HEAD^1 + */ + String calculatePatchOfNewSearchResults(String sourceBranch) throws IOException, GitAPIException { + try (Git git = Git.open(this.repositoryPathAsFile)) { + Optional sourceBranchRef = getRefForBranch(sourceBranch); + if (sourceBranchRef.isEmpty()) { + return ""; + } + Repository repository = git.getRepository(); + ObjectId branchHead = sourceBranchRef.get().getObjectId(); + ObjectId treeIdHead = repository.resolve(branchHead.getName() + "^{tree}"); + ObjectId treeIdHeadParent = repository.resolve(branchHead.getName() + "~1^{tree}"); + + try (ObjectReader reader = repository.newObjectReader()) { + CanonicalTreeParser oldTreeIter = new CanonicalTreeParser(); + oldTreeIter.reset(reader, treeIdHeadParent); + CanonicalTreeParser newTreeIter = new CanonicalTreeParser(); + newTreeIter.reset(reader, treeIdHead); + + ByteArrayOutputStream put = new ByteArrayOutputStream(); + try (DiffFormatter formatter = new DiffFormatter(put)) { + formatter.setRepository(git.getRepository()); + List entries = formatter.scan(oldTreeIter, newTreeIter); + for (DiffEntry entry : entries) { + if (entry.getChangeType().equals(DiffEntry.ChangeType.MODIFY)) { + formatter.format(entry); + } + } + formatter.flush(); + return put.toString(); + } + } + } + } + + /** + * Applies the provided patch on the current branch + * Ignores any changes made to the study definition file. + * The reason for this is that the study definition file cannot be patched the same way as the bib files, as the + * order of fields in the yml file matters. + * + * @param patch the patch (diff) as a string + * @return Returns a map where each file has its path as a key and the string contains the hunk of new results + */ + Map parsePatchForAddedEntries(String patch) throws IOException, GitAPIException { + String[] tokens = patch.split("\n"); + // Tracks for each file the related diff. Represents each file by its relative path + Map diffsPerFile = new HashMap<>(); + boolean content = false; + StringJoiner joiner = null; + String relativePath = null; + for (String currentToken : tokens) { + // Begin of a new diff + if (currentToken.startsWith("diff --git a/")) { + // If the diff is related to a different file, save the diff for the previous file + if (!(Objects.isNull(relativePath) || Objects.isNull(joiner))) { + if (!relativePath.contains("study.yml")) { + diffsPerFile.put(Path.of(repositoryPath.toString(), relativePath), joiner.toString()); + } + } + // Find the relative path of the file that is related with the current diff + relativePath = currentToken.substring(13, currentToken.indexOf(" b/")); + content = false; + joiner = new StringJoiner("\n"); + continue; + } + // From here on content follows + if (currentToken.startsWith("@@ ") && currentToken.endsWith(" @@")) { + content = true; + continue; + } + // Only add "new" lines to diff (no context lines) + if (content && currentToken.startsWith("+")) { + // Do not include + sign + if (joiner != null) { + joiner.add(currentToken.substring(1)); + } + } + } + if (!(Objects.isNull(relativePath) || Objects.isNull(joiner))) { + // For the last file this has to be done at the end + diffsPerFile.put(Path.of(repositoryPath.toString(), relativePath), joiner.toString()); + } + return diffsPerFile; + } + + /** + * Applies for each file (specified as keys), the calculated patch (specified as the value) + * The patch is inserted between the encoding and the contents of the bib files. + */ + void applyPatch(Map patch) { + patch.keySet().forEach(path -> { + try { + String currentContent = Files.readString(path); + String prefix = ""; + if (currentContent.startsWith("% Encoding:")) { + int endOfEncoding = currentContent.indexOf("\n"); + // Include Encoding and the empty line + prefix = currentContent.substring(0, endOfEncoding + 1) + "\n"; + currentContent = currentContent.substring(endOfEncoding + 2); + } + Files.writeString(path, prefix + patch.get(path) + currentContent, StandardCharsets.UTF_8); + } catch (IOException e) { + LOGGER.error("Could not apply patch."); + } + }); + } +} diff --git a/src/main/java/org/jabref/model/study/Study.java b/src/main/java/org/jabref/model/study/Study.java index 382268ce39a..cc117f0160e 100644 --- a/src/main/java/org/jabref/model/study/Study.java +++ b/src/main/java/org/jabref/model/study/Study.java @@ -127,6 +127,31 @@ public boolean equals(Object o) { return getDatabases() != null ? getDatabases().equals(study.getDatabases()) : study.getDatabases() == null; } + public boolean equalsBesideLastSearchDate(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Study study = (Study) o; + + if (getAuthors() != null ? !getAuthors().equals(study.getAuthors()) : study.getAuthors() != null) { + return false; + } + if (getTitle() != null ? !getTitle().equals(study.getTitle()) : study.getTitle() != null) { + return false; + } + if (getResearchQuestions() != null ? !getResearchQuestions().equals(study.getResearchQuestions()) : study.getResearchQuestions() != null) { + return false; + } + if (getQueries() != null ? !getQueries().equals(study.getQueries()) : study.getQueries() != null) { + return false; + } + return getDatabases() != null ? getDatabases().equals(study.getDatabases()) : study.getDatabases() == null; + } + @Override public int hashCode() { return Objects.hashCode(this); diff --git a/src/main/resources/l10n/JabRef_en.properties b/src/main/resources/l10n/JabRef_en.properties index 6073699969c..c6a9db1ab7c 100644 --- a/src/main/resources/l10n/JabRef_en.properties +++ b/src/main/resources/l10n/JabRef_en.properties @@ -629,7 +629,6 @@ Previous\ preview\ layout=Previous preview layout Available=Available Selected=Selected Selected\ Layouts\ can\ not\ be\ empty=Selected Layouts can not be empty -Start\ systematic\ literature\ review=Start systematic literature review Reset\ default\ preview\ style=Reset default preview style Previous\ entry=Previous entry Primary\ sort\ criterion=Primary sort criterion @@ -2319,7 +2318,6 @@ New\ entry\ by\ type=New entry by type File\ '%1'\ is\ a\ duplicate\ of\ '%0'.\ Keeping\ '%0'=File '%1' is a duplicate of '%0'. Keeping '%0' File\ '%1'\ is\ a\ duplicate\ of\ '%0'.\ Keeping\ both\ due\ to\ deletion\ error=File '%1' is a duplicate of '%0'. Keeping both due to deletion error - Enable\ field\ formatters=Enable field formatters Entry\ Type=Entry Type Entry\ types=Entry types @@ -2327,3 +2325,32 @@ Field\ names=Field names Others=Others Overwrite\ existing\ field\ values=Overwrite existing field values Recommended=Recommended + +Authors\ and\ Title=Authors and Title +Database=Database +Databases=Databases +Manage\ study\ definition=Manage study definition +Add\ Author\:=Add Author\: +Add\ Database\:=Add Database\: +Add\ Query\:=Add Query\: +Add\ Research\ Question\:=Add Research Question\: +Perform\ search\ for\ existing\ systematic\ literature\ review=Perform search for existing systematic literature review +Queries=Queries +Research\ Questions=Research Questions +Searching=Searching +Start\ new\ systematic\ literature\ review=Start new systematic literature review +Study\ Title\:=Study Title\: +Study\ repository\ could\ not\ be\ created=Study repository could not be created + +All\ query\ terms\ are\ joined\ using\ the\ logical\ AND,\ and\ OR\ operators=All query terms are joined using the logical AND, and OR operators +Finalize=Finalize +If\ the\ sequence\ of\ terms\ is\ relevant\ wrap\ them\ in\ double\ quotes =If the sequence of terms is relevant wrap them in double quotes +Query\ terms\ are\ separated\ by\ spaces.=Query terms are separated by spaces. +Select\ the\ study\ directory\:=Select the study directory\: +An\ example\:=An example\: +Define\ study\ parameters=Define study parameters +Start\ survey=Start survey +Query=Query +Question=Question +Select\ directory=Select directory + diff --git a/src/main/resources/org/jabref/logic/git/git.gitignore b/src/main/resources/org/jabref/logic/git/git.gitignore new file mode 100644 index 00000000000..dcff6d2d473 --- /dev/null +++ b/src/main/resources/org/jabref/logic/git/git.gitignore @@ -0,0 +1,4 @@ +### JabRef ### +# JabRef database specific files for persistence, not relevant to persist +*.sav +*.bak diff --git a/src/test/java/org/jabref/logic/crawler/CrawlerTest.java b/src/test/java/org/jabref/logic/crawler/CrawlerTest.java index b07dc48c59f..6dcc82f08ac 100644 --- a/src/test/java/org/jabref/logic/crawler/CrawlerTest.java +++ b/src/test/java/org/jabref/logic/crawler/CrawlerTest.java @@ -1,6 +1,7 @@ package org.jabref.logic.crawler; import java.net.URL; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -8,8 +9,8 @@ import org.jabref.logic.bibtex.FieldContentFormatterPreferences; import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; import org.jabref.logic.citationkeypattern.GlobalCitationKeyPattern; -import org.jabref.logic.crawler.git.GitHandler; import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.preferences.TimestampPreferences; import org.jabref.logic.util.io.FileUtil; @@ -37,22 +38,14 @@ class CrawlerTest { SavePreferences savePreferences; TimestampPreferences timestampPreferences; BibEntryTypesManager entryTypesManager; - GitHandler gitHandler = mock(GitHandler.class, Answers.RETURNS_DEFAULTS); + SlrGitHandler gitHandler = mock(SlrGitHandler.class, Answers.RETURNS_DEFAULTS); String hashCodeQuantum = String.valueOf("Quantum".hashCode()); String hashCodeCloudComputing = String.valueOf("Cloud Computing".hashCode()); - String hashCodeSoftwareEngineering = String.valueOf("\"Software Engineering\"".hashCode()); @Test public void testWhetherAllFilesAreCreated() throws Exception { setUp(); - Crawler testCrawler = new Crawler(getPathToStudyDefinitionFile(), - gitHandler, - new DummyFileUpdateMonitor(), - importFormatPreferences, - savePreferences, - timestampPreferences, - entryTypesManager - ); + Crawler testCrawler = new Crawler(getPathToStudyDefinitionFile(), gitHandler, importFormatPreferences, savePreferences, timestampPreferences, entryTypesManager, new DummyFileUpdateMonitor()); testCrawler.performCrawl(); @@ -71,7 +64,7 @@ public void testWhetherAllFilesAreCreated() throws Exception { } private Path getPathToStudyDefinitionFile() { - return tempRepositoryDirectory.resolve("study.yml"); + return tempRepositoryDirectory; } /** @@ -98,6 +91,7 @@ private void setUp() throws Exception { when(savePreferences.getEncoding()).thenReturn(null); when(savePreferences.takeMetadataSaveOrderInAccount()).thenReturn(true); when(savePreferences.getCitationKeyPatternPreferences()).thenReturn(citationKeyPatternPreferences); + when(savePreferences.getEncoding()).thenReturn(Charset.defaultCharset()); when(importFormatPreferences.getKeywordSeparator()).thenReturn(','); when(importFormatPreferences.getFieldContentFormatterPreferences()).thenReturn(new FieldContentFormatterPreferences()); when(importFormatPreferences.isKeywordSyncEnabled()).thenReturn(false); diff --git a/src/test/java/org/jabref/logic/crawler/StudyDatabaseToFetcherConverterTest.java b/src/test/java/org/jabref/logic/crawler/StudyDatabaseToFetcherConverterTest.java index f850658fefb..74ba78baa1a 100644 --- a/src/test/java/org/jabref/logic/crawler/StudyDatabaseToFetcherConverterTest.java +++ b/src/test/java/org/jabref/logic/crawler/StudyDatabaseToFetcherConverterTest.java @@ -6,8 +6,8 @@ import java.util.List; import org.jabref.logic.bibtex.FieldContentFormatterPreferences; -import org.jabref.logic.crawler.git.GitHandler; import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.importer.SearchBasedFetcher; import org.jabref.logic.preferences.TimestampPreferences; @@ -30,7 +30,7 @@ class StudyDatabaseToFetcherConverterTest { SavePreferences savePreferences; TimestampPreferences timestampPreferences; BibEntryTypesManager entryTypesManager; - GitHandler gitHandler; + SlrGitHandler gitHandler; @TempDir Path tempRepositoryDirectory; @@ -47,7 +47,7 @@ void setUpMocks() { when(importFormatPreferences.isKeywordSyncEnabled()).thenReturn(false); when(importFormatPreferences.getEncoding()).thenReturn(StandardCharsets.UTF_8); entryTypesManager = new BibEntryTypesManager(); - gitHandler = mock(GitHandler.class, Answers.RETURNS_DEFAULTS); + gitHandler = mock(SlrGitHandler.class, Answers.RETURNS_DEFAULTS); } @Test diff --git a/src/test/java/org/jabref/logic/crawler/StudyRepositoryTest.java b/src/test/java/org/jabref/logic/crawler/StudyRepositoryTest.java index 8bba87c2a38..d4becff0a1d 100644 --- a/src/test/java/org/jabref/logic/crawler/StudyRepositoryTest.java +++ b/src/test/java/org/jabref/logic/crawler/StudyRepositoryTest.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.URL; +import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -14,9 +15,9 @@ import org.jabref.logic.citationkeypattern.CitationKeyGenerator; import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; import org.jabref.logic.citationkeypattern.GlobalCitationKeyPattern; -import org.jabref.logic.crawler.git.GitHandler; import org.jabref.logic.database.DatabaseMerger; import org.jabref.logic.exporter.SavePreferences; +import org.jabref.logic.git.SlrGitHandler; import org.jabref.logic.importer.ImportFormatPreferences; import org.jabref.logic.preferences.TimestampPreferences; import org.jabref.logic.util.io.FileUtil; @@ -53,7 +54,7 @@ class StudyRepositoryTest { @TempDir Path tempRepositoryDirectory; StudyRepository studyRepository; - GitHandler gitHandler = mock(GitHandler.class, Answers.RETURNS_DEFAULTS); + SlrGitHandler gitHandler = mock(SlrGitHandler.class, Answers.RETURNS_DEFAULTS); String hashCodeQuantum = String.valueOf("Quantum".hashCode()); String hashCodeCloudComputing = String.valueOf("Cloud Computing".hashCode()); String hashCodeSoftwareEngineering = String.valueOf("\"Software Engineering\"".hashCode()); @@ -79,6 +80,7 @@ public void setUpMocks() throws Exception { when(savePreferences.getSaveOrder()).thenReturn(new SaveOrderConfig()); when(savePreferences.getEncoding()).thenReturn(null); when(savePreferences.takeMetadataSaveOrderInAccount()).thenReturn(true); + when(savePreferences.getEncoding()).thenReturn(Charset.defaultCharset()); when(savePreferences.getCitationKeyPatternPreferences()).thenReturn(citationKeyPatternPreferences); when(importFormatPreferences.getKeywordSeparator()).thenReturn(','); when(importFormatPreferences.getFieldContentFormatterPreferences()).thenReturn(new FieldContentFormatterPreferences()); diff --git a/src/test/java/org/jabref/logic/git/GitHandlerTest.java b/src/test/java/org/jabref/logic/git/GitHandlerTest.java new file mode 100644 index 00000000000..6f5e30052cf --- /dev/null +++ b/src/test/java/org/jabref/logic/git/GitHandlerTest.java @@ -0,0 +1,58 @@ +package org.jabref.logic.git; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Iterator; + +import org.eclipse.jgit.api.Git; +import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.lib.AnyObjectId; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.revwalk.RevCommit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class GitHandlerTest { + @TempDir + Path repositoryPath; + private GitHandler gitHandler; + + @BeforeEach + public void setUpGitHandler() { + gitHandler = new GitHandler(repositoryPath); + } + + @Test + void checkoutNewBranch() throws IOException, GitAPIException { + gitHandler.checkoutBranch("testBranch"); + + try (Git git = Git.open(repositoryPath.toFile())) { + assertEquals("testBranch", git.getRepository().getBranch()); + } + } + + @Test + void createCommitOnCurrentBranch() throws IOException, GitAPIException { + try (Git git = Git.open(repositoryPath.toFile())) { + // Create commit + Files.createFile(Path.of(repositoryPath.toString(), "Test.txt")); + gitHandler.createCommitOnCurrentBranch("TestCommit", false); + + AnyObjectId head = git.getRepository().resolve(Constants.HEAD); + Iterator log = git.log() + .add(head) + .call().iterator(); + assertEquals("TestCommit", log.next().getFullMessage()); + assertEquals("Initial commit", log.next().getFullMessage()); + } + } + + @Test + void getCurrentlyCheckedOutBranch() throws IOException { + assertEquals("master", gitHandler.getCurrentlyCheckedOutBranch()); + } +} diff --git a/src/test/java/org/jabref/logic/git/SlrGitHandlerTest.java b/src/test/java/org/jabref/logic/git/SlrGitHandlerTest.java new file mode 100644 index 00000000000..d18c3374e7c --- /dev/null +++ b/src/test/java/org/jabref/logic/git/SlrGitHandlerTest.java @@ -0,0 +1,83 @@ +package org.jabref.logic.git; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jgit.api.errors.GitAPIException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class SlrGitHandlerTest { + @TempDir + Path repositoryPath; + private SlrGitHandler gitHandler; + + @BeforeEach + public void setUpGitHandler() { + gitHandler = new SlrGitHandler(repositoryPath); + } + + @Test + void calculateDiffOnBranch() throws IOException, GitAPIException { + String expectedPatch = + "diff --git a/TestFolder/Test1.txt b/TestFolder/Test1.txt\n" + + "index 74809e3..2ae1945 100644\n" + + "--- a/TestFolder/Test1.txt\n" + + "+++ b/TestFolder/Test1.txt\n" + + "@@ -1 +1,2 @@\n" + + "+This is a new line of text 2\n" + + " This is a new line of text\n"; + + gitHandler.checkoutBranch("branch1"); + Files.createDirectory(Path.of(repositoryPath.toString(), "TestFolder")); + Files.createFile(Path.of(repositoryPath.toString(), "TestFolder", "Test1.txt")); + Files.writeString(Path.of(repositoryPath.toString(), "TestFolder", "Test1.txt"), "This is a new line of text\n"); + gitHandler.createCommitOnCurrentBranch("Commit 1 on branch1", false); + + Files.createFile(Path.of(repositoryPath.toString(), "Test2.txt")); + Files.writeString(Path.of(repositoryPath.toString(), "TestFolder", "Test1.txt"), "This is a new line of text 2\n" + Files.readString(Path.of(repositoryPath.toString(), "TestFolder", "Test1.txt"))); + gitHandler.createCommitOnCurrentBranch("Commit 2 on branch1", false); + + System.out.println(gitHandler.calculatePatchOfNewSearchResults("branch1")); + assertEquals(expectedPatch, gitHandler.calculatePatchOfNewSearchResults("branch1")); + } + + @Test + void calculatePatch() throws IOException, GitAPIException { + Map expected = new HashMap<>(); + expected.put(Path.of(repositoryPath.toString(), "TestFolder", "Test1.txt"), "This is a new line of text 2"); + + Map result = gitHandler.parsePatchForAddedEntries( + "diff --git a/TestFolder/Test1.txt b/TestFolder/Test1.txt\n" + + "index 74809e3..2ae1945 100644\n" + + "--- a/TestFolder/Test1.txt\n" + + "+++ b/TestFolder/Test1.txt\n" + + "@@ -1 +1,2 @@\n" + + "+This is a new line of text 2\n" + + " This is a new line of text"); + + assertEquals(expected, result); + } + + @Test + void applyPatch() throws IOException, GitAPIException { + gitHandler.checkoutBranch("branch1"); + Files.createFile(Path.of(repositoryPath.toString(), "Test1.txt")); + gitHandler.createCommitOnCurrentBranch("Commit on branch1", false); + gitHandler.checkoutBranch("branch2"); + Files.createFile(Path.of(repositoryPath.toString(), "Test2.txt")); + Files.writeString(Path.of(repositoryPath.toString(), "Test1.txt"), "This is a new line of text"); + gitHandler.createCommitOnCurrentBranch("Commit on branch2.", false); + + gitHandler.checkoutBranch("branch1"); + gitHandler.appendLatestSearchResultsOntoCurrentBranch("TestMessage", "branch2"); + + assertEquals("This is a new line of text", Files.readString(Path.of(repositoryPath.toString(), "Test1.txt"))); + } +}