package de.pbauerochse.worklogviewer.fx; import com.google.common.collect.ImmutableList; import de.pbauerochse.worklogviewer.WorklogViewer; import de.pbauerochse.worklogviewer.domain.Callback; import de.pbauerochse.worklogviewer.domain.ReportTimerange; import de.pbauerochse.worklogviewer.domain.TimerangeProvider; import de.pbauerochse.worklogviewer.domain.timerangeprovider.TimerangeProviderFactory; import de.pbauerochse.worklogviewer.fx.converter.GroupByCategoryStringConverter; import de.pbauerochse.worklogviewer.fx.converter.ReportTimerangeStringConverter; import de.pbauerochse.worklogviewer.fx.tabs.AllWorklogsTab; import de.pbauerochse.worklogviewer.fx.tabs.OwnWorklogsTab; import de.pbauerochse.worklogviewer.fx.tabs.ProjectWorklogTab; import de.pbauerochse.worklogviewer.fx.tabs.WorklogTab; import de.pbauerochse.worklogviewer.fx.tasks.*; import de.pbauerochse.worklogviewer.util.ExceptionUtil; import de.pbauerochse.worklogviewer.util.FormattingUtil; import de.pbauerochse.worklogviewer.util.HyperlinkUtil; import de.pbauerochse.worklogviewer.util.SettingsUtil; import de.pbauerochse.worklogviewer.version.GitHubVersion; import de.pbauerochse.worklogviewer.version.Version; import de.pbauerochse.worklogviewer.youtrack.domain.GroupByCategory; import javafx.beans.binding.BooleanBinding; import javafx.concurrent.Task; import javafx.concurrent.WorkerStateEvent; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.fxml.FXMLLoader; import javafx.fxml.Initializable; import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.layout.StackPane; import javafx.scene.text.Text; import javafx.stage.FileChooser; import javafx.stage.Modality; import javafx.stage.Stage; import javafx.stage.StageStyle; import org.apache.commons.lang3.StringUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.File; import java.io.IOException; import java.net.URL; import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.ResourceBundle; import java.util.concurrent.*; /** * @author Patrick Bauerochse * @since 01.04.15 */ public class MainViewController implements Initializable { private static final Logger LOGGER = LoggerFactory.getLogger(MainViewController.class); private static final int AMOUNT_OF_FIXED_TABS_BEFORE_PROJECT_TABS = 2; // two fixed tabs (own and all) private static final String REQUIRED_FIELD_CLASS = "required"; public static ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(1, 1, 1, TimeUnit.MINUTES, new LinkedBlockingQueue<>()); @FXML private ComboBox<ReportTimerange> timerangeComboBox; @FXML private ComboBox<Optional<GroupByCategory>> groupByCategoryComboBox; @FXML private Button fetchWorklogButton; @FXML private MenuItem exportToExcelMenuItem; @FXML private MenuItem settingsMenuItem; @FXML private MenuItem logMessagesMenuItem; @FXML private MenuItem aboutMenuItem; @FXML private MenuItem exitMenuItem; @FXML private ProgressBar progressBar; @FXML private Text progressText; @FXML private TabPane resultTabPane; @FXML private StackPane waitScreenOverlay; @FXML private DatePicker startDatePicker; @FXML private DatePicker endDatePicker; @FXML private ToolBar mainToolbar; private ResourceBundle resources; private SettingsUtil.Settings settings; @Override public void initialize(URL location, ResourceBundle resources) { LOGGER.debug("Initializing main view"); this.resources = resources; settings = SettingsUtil.loadSettings(); // prepopulate timerange dropdown timerangeComboBox.setConverter(new ReportTimerangeStringConverter()); timerangeComboBox.getItems().addAll(ReportTimerange.values()); timerangeComboBox.getSelectionModel().selectedItemProperty().addListener((observable, oldValue, newValue) -> { if (newValue != null) { // update settings settings.setLastUsedReportTimerange(newValue); // prepopulate start and end datepickers and remove error labels TimerangeProvider timerangeProvider = TimerangeProviderFactory.getTimerangeProvider(newValue, null, null); startDatePicker.setValue(timerangeProvider.getStartDate()); endDatePicker.setValue(timerangeProvider.getEndDate()); } }); // prepopulate report timerange combobox with last used value timerangeComboBox.getSelectionModel().select(settings.getLastUsedReportTimerange()); // group by combobox converter groupByCategoryComboBox.disableProperty().bind(groupByCategoryComboBox.itemsProperty().isNull()); groupByCategoryComboBox.setConverter(new GroupByCategoryStringConverter(groupByCategoryComboBox)); // start and end datepicker are only editable if report timerange is CUSTOM startDatePicker.disableProperty().bind(timerangeComboBox.getSelectionModel().selectedItemProperty().isNotEqualTo(ReportTimerange.CUSTOM)); startDatePicker.valueProperty().addListener((observable, oldValue, newValue) -> { if (newValue == null) { startDatePicker.getStyleClass().add(REQUIRED_FIELD_CLASS); } else { startDatePicker.getStyleClass().remove(REQUIRED_FIELD_CLASS); } }); endDatePicker.disableProperty().bind(timerangeComboBox.getSelectionModel().selectedItemProperty().isNotEqualTo(ReportTimerange.CUSTOM)); endDatePicker.valueProperty().addListener((observable, oldValue, newValue) -> { if (newValue == null) { endDatePicker.getStyleClass().add(REQUIRED_FIELD_CLASS); } else { endDatePicker.getStyleClass().remove(REQUIRED_FIELD_CLASS); } }); // fetch worklog button click fetchWorklogButton.disableProperty().bind(new BooleanBinding() { @Override protected boolean computeValue() { return settings.hasMissingConnectionParameters(); } }); fetchWorklogButton.setOnAction(clickEvent -> startFetchWorklogsTask()); // export to excel only possible if resultTabPane is not empty and therefore seems to contain data exportToExcelMenuItem.disableProperty().bind(resultTabPane.getSelectionModel().selectedItemProperty().isNull()); // menu items click actions exportToExcelMenuItem.setOnAction(event -> startExportToExcelTask()); settingsMenuItem.setOnAction(event -> showSettingsDialogue()); exitMenuItem.setOnAction(event -> WorklogViewer.getInstance().requestShutdown()); logMessagesMenuItem.setOnAction(event -> showLogMessagesDialogue()); aboutMenuItem.setOnAction(event -> showAboutDialogue()); // workaround to detect whether the whole form has been rendered to screen yet progressBar.sceneProperty().addListener((observable, oldValue, newValue) -> { if (oldValue == null && newValue != null) { onFormShown(); } }); // load group by criteria when connection parameters are present if (!settings.hasMissingConnectionParameters()) { startGetGroupByCategoriesTask(); } // check for update VersionCheckerTask versionCheckTask = new VersionCheckerTask(); versionCheckTask.setOnSucceeded(event -> { GitHubVersion gitHubVersion = ((VersionCheckerTask) event.getSource()).getValue(); if (gitHubVersion != null) { Version currentVersion = new Version(resources.getString("release.version")); Version mostRecentVersion = new Version(gitHubVersion.getVersion()); LOGGER.debug("Most recent github version is {}, this version is {}", mostRecentVersion, currentVersion); if (mostRecentVersion.isNewerThan(currentVersion)) { Hyperlink link = HyperlinkUtil.createLink( FormattingUtil.getFormatted("worker.updatecheck.available", mostRecentVersion.toString()), gitHubVersion.getUrl() ); mainToolbar.getItems().add(link); } } }); startTask(versionCheckTask); } private void onFormShown() { LOGGER.debug("MainForm shown"); if (settings.hasMissingConnectionParameters()) { LOGGER.info("No YouTrack connection settings defined yet. Opening settings dialogue"); showSettingsDialogue(); } // auto load data if a named timerange was selected // and the user chose to load data at startup if (timerangeComboBox.getSelectionModel().getSelectedItem() != ReportTimerange.CUSTOM && settings.isLoadDataAtStartup()) { LOGGER.debug("loadDataAtStartup set. Loading report for {}", timerangeComboBox.getSelectionModel().getSelectedItem().name()); fetchWorklogButton.fire(); } } /** * Fetches groupBy criteria from YouTrack */ private void startGetGroupByCategoriesTask() { LOGGER.info("Fetching GroupByCategories"); GetGroupByCategoriesTask task = new GetGroupByCategoriesTask(); task.setOnSucceeded(event -> { List<GroupByCategory> categoryList = (List<GroupByCategory>) event.getSource().getValue(); LOGGER.info("{} succeeded with {} GroupByCategories", task.getTitle(), categoryList.size()); groupByCategoryComboBox.getItems().add(Optional.empty()); categoryList.forEach(groupByCategory -> groupByCategoryComboBox.getItems().add(Optional.of(groupByCategory))); groupByCategoryComboBox.getSelectionModel().select(0); }); startTask(task); } /** * Exports the currently visible data to an excel spreadsheet */ private void startExportToExcelTask() { // currently visible tab WorklogTab tab = (WorklogTab) resultTabPane.getSelectionModel().getSelectedItem(); // ask the user where to save the excel to FileChooser fileChooser = new FileChooser(); fileChooser.setTitle(FormattingUtil.getFormatted("view.menu.file.exportexcel")); fileChooser.setInitialFileName(tab.getExcelDownloadSuggestedFilename()); fileChooser.setSelectedExtensionFilter(new FileChooser.ExtensionFilter("Microsoft Excel", "*.xls")); File targetFile = fileChooser.showSaveDialog(progressBar.getScene().getWindow()); if (targetFile != null) { LOGGER.debug("Exporting tab {} to excel {}", tab.getText(), targetFile.getAbsoluteFile()); ExcelExporterTask excelExporterTask = new ExcelExporterTask(tab, targetFile); excelExporterTask.setOnSucceeded(event -> { LOGGER.info("Excel creation succeeded"); File result = (File) event.getSource().getValue(); progressText.setText(FormattingUtil.getFormatted("exceptions.excel.success", result.getAbsoluteFile())); }); startTask(excelExporterTask); } } /** * Fetches the worklogs for the currently defined settings from YouTrack */ private void startFetchWorklogsTask() { // sanity checks LocalDate selectedStartDate = startDatePicker.getValue(); LocalDate selectedEndDate = endDatePicker.getValue(); if (selectedStartDate == null || selectedEndDate == null) { LOGGER.warn("Startdate or enddate were null"); progressText.setText(FormattingUtil.getFormatted("exceptions.timerange.datesrequired")); return; } else if (selectedStartDate.isAfter(selectedEndDate)) { LOGGER.warn("Startdate was after enddate"); progressText.setText(FormattingUtil.getFormatted("exceptions.timerange.startafterend")); return; } // start the task ReportTimerange timerange = timerangeComboBox.getSelectionModel().getSelectedItem(); LOGGER.debug("Fetch worklogs clicked for timerange {}", timerange.toString()); TimerangeProvider timerangeProvider = TimerangeProviderFactory.getTimerangeProvider(timerange, selectedStartDate, selectedEndDate); FetchTimereportContext context = new FetchTimereportContext(timerangeProvider, groupByCategoryComboBox.getSelectionModel().getSelectedItem()); FetchTimereportTask fetchTimereportTask = new FetchTimereportTask(context); // success handler fetchTimereportTask.setOnSucceeded(event -> { LOGGER.info("Fetching worklogs succeeded"); displayWorklogResult(context, settings); }); startTask(fetchTimereportTask); } /** * Starts a thread performing the given task * @param task The task to perform */ private void startTask(Task task) { LOGGER.info("Starting task {}", task.getTitle()); EventHandler onRunningEventHandler = task.getOnRunning(); task.setOnRunning(event -> { waitScreenOverlay.setVisible(true); progressText.textProperty().bind(task.messageProperty()); progressBar.progressProperty().bind(task.progressProperty()); if (onRunningEventHandler != null) { onRunningEventHandler.handle(event); } }); // success handler EventHandler<WorkerStateEvent> onSucceededEventHandler = task.getOnSucceeded(); task.setOnSucceeded(event -> { LOGGER.info("Task {} succeeded", task.getTitle()); WorkerStateEvent asWorkerstateEvent = (WorkerStateEvent) event; // stupid compiler sometimes gets confused in lambdas // unbind progress indicators progressText.textProperty().unbind(); progressBar.progressProperty().unbind(); if (onSucceededEventHandler != null) { LOGGER.debug("Delegating Event to previous onSucceeded event handler"); onSucceededEventHandler.handle(asWorkerstateEvent); } waitScreenOverlay.setVisible(false); }); // error handler EventHandler<WorkerStateEvent> onFailedEventHandler = task.getOnFailed(); task.setOnFailed(event -> { LOGGER.warn("Task {} failed", task.getTitle()); WorkerStateEvent asWorkerstateEvent = (WorkerStateEvent) event; // stupid compiler sometimes gets confused in lambdas // unbind progress indicators progressText.textProperty().unbind(); progressBar.progressProperty().unbind(); if (onFailedEventHandler != null) { LOGGER.debug("Delegating Event to previous onFailed event handler"); onFailedEventHandler.handle(asWorkerstateEvent); } Throwable throwable = asWorkerstateEvent.getSource().getException(); if (throwable != null && StringUtils.isNotBlank(throwable.getMessage())) { LOGGER.warn("Showing error to user", throwable); progressText.setText(throwable.getMessage()); } else { if (throwable != null) { LOGGER.warn("Error executing task {}", task.toString(), throwable); } progressText.setText(FormattingUtil.getFormatted("exceptions.main.worker.unknown")); } progressBar.setProgress(1); waitScreenOverlay.setVisible(false); }); // state change listener just for logging purposes task.stateProperty().addListener((observable, oldValue, newValue) -> LOGGER.debug("Task {} changed from {} to {}", task.getTitle(), oldValue, newValue)); EXECUTOR.submit(task); } private void displayWorklogResult(FetchTimereportContext context, SettingsUtil.Settings settings) { LOGGER.info("Displaying WorklogResult to the user"); if (resultTabPane.getTabs().size() == 0) { LOGGER.debug("Adding default tabs"); resultTabPane.getTabs().add(new OwnWorklogsTab()); } if (settings.isShowAllWorklogs()) { if (resultTabPane.getTabs().size() < 2 || !(resultTabPane.getTabs().get(1) instanceof AllWorklogsTab)) { resultTabPane.getTabs().add(new AllWorklogsTab()); } ImmutableList<String> distinctProjectNames = context.getResult().get().getDistinctProjectNames(); for (int i = 0; i < distinctProjectNames.size(); i++) { int tabIndex = AMOUNT_OF_FIXED_TABS_BEFORE_PROJECT_TABS + i; String newTabLabel = distinctProjectNames.get(i); WorklogTab tab; if (resultTabPane.getTabs().size() > tabIndex) { // there is a tab we can reuse tab = (WorklogTab) resultTabPane.getTabs().get(tabIndex); LOGGER.debug("Reusing Tab {} for project {}", tab.getText(), newTabLabel); } else { LOGGER.debug("Adding new project tab for project {}", newTabLabel); tab = new ProjectWorklogTab(newTabLabel); resultTabPane.getTabs().add(tab); } tab.setText(newTabLabel); } // remove any redundant tabs for (int tabIndexToRemove = distinctProjectNames.size() + AMOUNT_OF_FIXED_TABS_BEFORE_PROJECT_TABS; tabIndexToRemove < resultTabPane.getTabs().size(); tabIndexToRemove++) { WorklogTab removedTab = (WorklogTab) resultTabPane.getTabs().remove(tabIndexToRemove); LOGGER.debug("Removing tab at index {}: {}", tabIndexToRemove, removedTab.getText()); } } else if (resultTabPane.getTabs().size() > 1) { // remove all other tabs when settings changed from showAll to showOnlyOwnWorklogs LOGGER.debug("Removing all and project tabs since user switched to showOnlyOwnWorklogs mode"); resultTabPane.getTabs().remove(1, resultTabPane.getTabs().size()); resultTabPane.getSelectionModel().select(0); } resultTabPane.getTabs().forEach(tab -> ((WorklogTab) tab).updateItems(context)); } private void showSettingsDialogue() { LOGGER.debug("Showing settings dialogue"); // pass in a handler to fetch the group by categories if connection // parameters get set openDialogue("/fx/views/settings.fxml", "view.settings.title", true, Optional.of(() -> { if (!settings.hasMissingConnectionParameters() && groupByCategoryComboBox.getItems().size() == 0) { LOGGER.debug("Settings window closed, connection settings set and groupBy combobox empty -> trying to fetch groupByCategories"); startGetGroupByCategoriesTask(); } })); } private void showLogMessagesDialogue() { LOGGER.debug("Showing log messages dialogue"); openDialogue("/fx/views/logMessagesView.fxml", "view.menu.help.logs", false); } private void showAboutDialogue() { LOGGER.debug("Showing log messages dialogue"); openDialogue("/fx/views/about.fxml", "view.menu.help.about", true); } private void openDialogue(String view, String titleResourceKey, boolean modal) { openDialogue(view, titleResourceKey, modal, Optional.empty()); } private void openDialogue(String view, String titleResourceKey, boolean modal, Optional<Callback> onCloseCallback) { try { Parent content = FXMLLoader.load(MainViewController.class.getResource(view), resources); Scene scene = new Scene(content); scene.getStylesheets().add("/fx/css/main.css"); Stage stage = new Stage(); stage.initOwner(progressBar.getScene().getWindow()); if (modal) { stage.initStyle(StageStyle.UTILITY); stage.initModality(Modality.APPLICATION_MODAL); stage.setResizable(false); } stage.setTitle(FormattingUtil.getFormatted(titleResourceKey)); stage.setScene(scene); if (onCloseCallback.isPresent()) { stage.setOnCloseRequest(event -> { LOGGER.debug("View {} got close request. Notifying callback", view); onCloseCallback.get().invoke(); }); } stage.showAndWait(); } catch (IOException e) { LOGGER.error("Could not open dialogue {}", view, e); throw ExceptionUtil.getRuntimeException("exceptions.view.io", e, view); } } }