/*********************************************************************************** * * Copyright (c) 2014 Kamil Baczkowicz * * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * and Eclipse Distribution License v1.0 which accompany this distribution. * * The Eclipse Public License is available at * http://www.eclipse.org/legal/epl-v10.html * * The Eclipse Distribution License is available at * http://www.eclipse.org/org/documents/edl-v10.php. * * Contributors: * * Kamil Baczkowicz - initial API and implementation and/or initial documentation * */ package pl.baczkowicz.mqttspy.ui; import java.io.File; import java.io.IOException; import java.net.URL; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.ResourceBundle; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.ActionEvent; import javafx.event.Event; import javafx.event.EventHandler; import javafx.fxml.FXML; import javafx.fxml.Initializable; import javafx.scene.control.ButtonType; import javafx.scene.control.CheckBox; import javafx.scene.control.ChoiceBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.Menu; import javafx.scene.control.MenuButton; import javafx.scene.control.MenuItem; import javafx.scene.control.RadioMenuItem; import javafx.scene.control.SplitMenuButton; import javafx.scene.control.TitledPane; import javafx.scene.control.Toggle; import javafx.scene.control.ToggleGroup; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyEvent; import javafx.scene.layout.AnchorPane; import org.eclipse.paho.client.mqttv3.MqttMessage; import org.fxmisc.richtext.StyleClassedTextArea; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import pl.baczkowicz.mqttspy.common.generated.SimpleMqttMessage; import pl.baczkowicz.mqttspy.connectivity.MqttAsyncConnection; import pl.baczkowicz.mqttspy.messages.BaseMqttMessage; import pl.baczkowicz.mqttspy.scripts.MqttScriptManager; import pl.baczkowicz.mqttspy.ui.scripts.InteractiveScriptManager; import pl.baczkowicz.mqttspy.utils.MqttUtils; import pl.baczkowicz.spy.common.generated.ConversionMethod; import pl.baczkowicz.spy.eventbus.IKBus; import pl.baczkowicz.spy.exceptions.ConversionException; import pl.baczkowicz.spy.files.FileUtils; import pl.baczkowicz.spy.scripts.Script; import pl.baczkowicz.spy.ui.keyboard.TimeBasedKeyEventFilter; import pl.baczkowicz.spy.ui.panes.PaneVisibilityStatus; import pl.baczkowicz.spy.ui.panes.TitledPaneController; import pl.baczkowicz.spy.ui.properties.PublicationScriptProperties; import pl.baczkowicz.spy.ui.scripts.ScriptTypeEnum; import pl.baczkowicz.spy.ui.scripts.events.ScriptListChangeEvent; import pl.baczkowicz.spy.ui.threading.SimpleRunLaterExecutor; import pl.baczkowicz.spy.ui.utils.DialogFactory; import pl.baczkowicz.spy.utils.ConversionUtils; import pl.baczkowicz.spy.utils.TimeUtils; /** * Controller for creating new publications. */ public class NewPublicationController implements Initializable, TitledPaneController { /** Diagnostic logger. */ private final static Logger logger = LoggerFactory.getLogger(NewPublicationController.class); /** How many recent messages to store. */ private final static int MAX_RECENT_MESSAGES = 10; private MenuButton settingsButton; @FXML private SplitMenuButton publishButton; @FXML private ToggleGroup publishScript; @FXML private ComboBox<String> publicationTopicText; @FXML private ChoiceBox<String> publicationQosChoice; @FXML private StyleClassedTextArea publicationData; @FXML private ToggleGroup formatGroup; @FXML private CheckBox retainedBox; @FXML private Label retainedLabel; @FXML private Label dataLabel; @FXML private Label publicationQosLabel; @FXML private Label lengthLabel; @FXML private MenuButton formatMenu; @FXML private Menu publishWithScriptsMenu; @FXML private Menu recentMessagesMenu; @FXML private Menu saveRecentMessagesMenu; private ObservableList<String> publicationTopics = FXCollections.observableArrayList(); private MqttAsyncConnection connection; private ConversionMethod formatSelected = ConversionMethod.PLAIN; private boolean connected; private boolean detailedView; private InteractiveScriptManager scriptManager; private Label titleLabel; private IKBus eventBus; private List<BaseMqttMessage> recentMessages = new ArrayList<>(); private TimeBasedKeyEventFilter timeBasedFilter; private TitledPane pane; private AnchorPane paneTitle; protected ConnectionController connectionController; public void initialize(URL location, ResourceBundle resources) { timeBasedFilter = new TimeBasedKeyEventFilter(500); publicationTopicText.setItems(publicationTopics); formatGroup.getToggles().get(0).setUserData(ConversionMethod.PLAIN); formatGroup.getToggles().get(1).setUserData(ConversionMethod.HEX_DECODE); formatGroup.getToggles().get(2).setUserData(ConversionMethod.BASE_64_DECODE); formatGroup.selectToggle(formatGroup.getToggles().get(0)); formatGroup.selectedToggleProperty().addListener(new ChangeListener<Toggle>() { @Override public void changed(ObservableValue<? extends Toggle> observable, Toggle oldValue, Toggle newValue) { // If plain has been selected if (newValue != null) { switch ((ConversionMethod) formatGroup.getSelectedToggle().getUserData()) { case BASE_64_DECODE: showAsBase64(); break; case HEX_DECODE: showAsHex(); break; case PLAIN: showAsPlain(); break; default: break; } } } }); publicationTopicText.addEventFilter(KeyEvent.KEY_PRESSED, new EventHandler<KeyEvent>() { @Override public void handle(KeyEvent keyEvent) { switch (keyEvent.getCode()) { case ENTER: { if (connected && timeBasedFilter.processEvent(keyEvent)) { publish(); keyEvent.consume(); } break; } case DIGIT0: { restoreFromKeypress(keyEvent, 0); break; } case DIGIT1: { restoreFromKeypress(keyEvent, 1); break; } case DIGIT2: { restoreFromKeypress(keyEvent, 2); break; } case DIGIT3: { restoreFromKeypress(keyEvent, 3); break; } case DIGIT4: { restoreFromKeypress(keyEvent, 4); break; } case DIGIT5: { restoreFromKeypress(keyEvent, 5); break; } case DIGIT6: { restoreFromKeypress(keyEvent, 6); break; } case DIGIT7: { restoreFromKeypress(keyEvent, 7); break; } case DIGIT8: { restoreFromKeypress(keyEvent, 8); break; } case DIGIT9: { restoreFromKeypress(keyEvent, 9); break; } default: break; } } }); publicationData.setWrapText(true); publicationData.setOnKeyReleased(new EventHandler<Event>() { @Override public void handle(Event event) { final BaseMqttMessage values = readMessage(false, true); String payload = ""; if (values != null) { payload = values.getPayload(); } MessageController.populatePayloadLength(lengthLabel, null, payload.length()); lengthLabel.getStyleClass().removeAll("newLinesPresent", "noNewLines"); if (payload.contains(ConversionUtils.LINE_SEPARATOR_LINUX) || payload.contains(ConversionUtils.LINE_SEPARATOR_WIN) || payload.contains(ConversionUtils.LINE_SEPARATOR_MAC)) { lengthLabel.getStyleClass().add("newLinesPresent"); } else { lengthLabel.getStyleClass().add("noNewLines"); } } }); publishScript.getToggles().get(0).setUserData(null); publishScript.selectedToggleProperty().addListener(new ChangeListener<Toggle>() { @Override public void changed(ObservableValue<? extends Toggle> observable, Toggle oldValue, Toggle newValue) { if (newValue.getUserData() == null) { publishButton.setText("Publish"); } else { publishButton.setText("Publish" + System.lineSeparator() + "with" + System.lineSeparator() + "script"); } } }); publishButton.setTooltip(new Tooltip("Publish message [" + ViewManager.newPublication.getDisplayText() + "]")); } public void init() { titleLabel = new Label(pane.getText()); eventBus.subscribe(this, this::onScriptListChange, ScriptListChangeEvent.class, new SimpleRunLaterExecutor(), connection); paneTitle = new AnchorPane(); settingsButton = ViewManager.createTitleButtons(this, paneTitle, connectionController); } public void onScriptListChange(final ScriptListChangeEvent event) { final List<PublicationScriptProperties> scripts = scriptManager.getObservableScriptList(); final List<Script> pubScripts = new ArrayList<>(); for (final PublicationScriptProperties properties : scripts) { if (ScriptTypeEnum.PUBLICATION.equals(properties.typeProperty().getValue())) { pubScripts.add(properties.getScript()); } } updateScriptList(pubScripts, publishWithScriptsMenu, publishScript, "Publish with '%s' script", null); } public static void updateScriptList(final List<Script> scripts, final Menu scriptsMenu, final ToggleGroup toggleGroup, final String format, final EventHandler<ActionEvent> eventHandler) { while (scriptsMenu.getItems().size() > 0) { scriptsMenu.getItems().remove(0); } if (scripts.size() > 0) { for (final Script script : scripts) { final RadioMenuItem item = new RadioMenuItem(String.format(format, script.getName())); item.setOnAction(eventHandler); item.setToggleGroup(toggleGroup); item.setUserData(script); scriptsMenu.getItems().add(item); } } } public void setConnectionController(final ConnectionController connectionController) { this.connectionController = connectionController; } public void recordPublicationTopic(final String publicationTopic) { MqttUtils.recordTopic(publicationTopic, publicationTopics); } public void setConnected(final boolean connected) { this.connected = connected; this.publishButton.setDisable(!connected); this.publicationTopicText.setDisable(!connected); } private void decodeToPlain() { if (formatSelected.equals(ConversionMethod.HEX_DECODE)) { try { final String convertedText = ConversionUtils.hexToString(publicationData.getText()); logger.info("Converted {} to {}", publicationData.getText(), convertedText); publicationData.clear(); publicationData.appendText(convertedText); formatMenu.setText("Input format: Plain"); } catch (ConversionException e) { showAndLogHexError(); formatGroup.selectToggle(formatGroup.getToggles().get(1)); formatMenu.setText("Input format: Hex"); } } else if (formatSelected.equals(ConversionMethod.BASE_64_DECODE)) { final String convertedText = ConversionUtils.base64ToString(publicationData.getText()); logger.info("Converted {} to {}", publicationData.getText(), convertedText); publicationData.clear(); publicationData.appendText(convertedText); formatMenu.setText("Input format: Plain"); } } @FXML public void showAsPlain() { if (!ConversionMethod.PLAIN.equals(formatSelected)) { decodeToPlain(); formatSelected = ConversionMethod.PLAIN; } } @FXML public void showAsHex() { if (!ConversionMethod.HEX_DECODE.equals(formatSelected)) { final BaseMqttMessage message = readMessage(false, false); // Use the raw format to ensure correct transformation between binary formats final String convertedText = ConversionUtils.arrayToHex(message.getRawMessage().getPayload()); logger.info("Converted {} to {}", publicationData.getText(), convertedText); publicationData.clear(); publicationData.appendText(convertedText); formatMenu.setText("Input format: Hex"); formatSelected = ConversionMethod.HEX_DECODE; } } @FXML public void showAsBase64() { if (!ConversionMethod.BASE_64_DECODE.equals(formatSelected)) { final BaseMqttMessage message = readMessage(false, false); // Use the raw format to ensure correct transformation between binary formats final String convertedText = ConversionUtils.arrayToBase64(message.getRawMessage().getPayload()); logger.info("Converted {} to {}", publicationData.getText(), convertedText); publicationData.clear(); publicationData.appendText(convertedText); formatMenu.setText("Input format: Base64"); formatSelected = ConversionMethod.BASE_64_DECODE; } } private void updateVisibility() { if (detailedView) { AnchorPane.setRightAnchor(publicationTopicText, 327.0); AnchorPane.setRightAnchor(publicationData, 326.0); AnchorPane.setTopAnchor(dataLabel, 31.0); } else { AnchorPane.setRightAnchor(publicationTopicText, 128.0); AnchorPane.setRightAnchor(publicationData, 127.0); AnchorPane.setTopAnchor(dataLabel, 37.0); } formatMenu.setVisible(detailedView); publicationQosChoice.setVisible(detailedView); publicationQosLabel.setVisible(detailedView); retainedBox.setVisible(detailedView); retainedLabel.setVisible(detailedView); lengthLabel.setVisible(detailedView); // TODO: basic perspective } public void setViewVisibility(final boolean detailedView) { this.detailedView = detailedView; updateVisibility(); } public void toggleDetailedViewVisibility() { detailedView = !detailedView; updateVisibility(); } /** * Displays the given message. * * @param message The message to display */ private void displayMessage(final BaseMqttMessage message) { displayMessage(new SimpleMqttMessage(message.getPayload(), message.getTopic(), message.getQoS(), message.isRetained())); } /** * Displays the given message. * * @param message The message to display */ public void displayMessage(final SimpleMqttMessage message) { if (message == null) { publicationTopicText.setValue(""); publicationTopicText.setPromptText("(cannot be empty)"); publicationQosChoice.getSelectionModel().select(0); publicationData.clear(); retainedBox.setSelected(false); } else { publicationTopicText.setValue(message.getTopic()); publicationQosChoice.getSelectionModel().select(message.getQos()); publicationData.clear(); publicationData.appendText(message.getValue()); retainedBox.setSelected(message.isRetained()); } } public BaseMqttMessage readMessage(final boolean verify, final boolean ignoreErrors) { // Note: here using the editor, as the value stored directly in the ComboBox might // not be committed yet, whereas the editor (TextField) has got the current text in it // Note: this is also a workaround for bug in JRE 8 Update 60-66 (https://bugs.openjdk.java.net/browse/JDK-8136838) final String topic = publicationTopicText.getEditor().getText(); if (verify && (topic == null || topic.isEmpty())) { logger.error("Cannot publish to an empty topic"); DialogFactory.createErrorDialog("Invalid topic", "Cannot publish to an empty topic."); return null; } final BaseMqttMessage message = new BaseMqttMessage(0, topic, new MqttMessage()); try { if (formatSelected.equals(ConversionMethod.PLAIN)) { final byte[] data = ConversionUtils.stringToArray(publicationData.getText()); message.getRawMessage().setPayload(data); } else if (formatSelected.equals(ConversionMethod.HEX_DECODE)) { final byte[] data = ConversionUtils.hexToArray(publicationData.getText()); message.getRawMessage().setPayload(data); } else if (formatSelected.equals(ConversionMethod.BASE_64_DECODE)) { final byte[] data = ConversionUtils.base64ToArray(publicationData.getText()); message.getRawMessage().setPayload(data); } message.getRawMessage().setQos(publicationQosChoice.getSelectionModel().getSelectedIndex()); message.getRawMessage().setRetained(retainedBox.isSelected()); return message; } catch (ConversionException e) { if (!ignoreErrors) { showAndLogHexError(); } return null; } } @FXML public void publish() { final BaseMqttMessage message = readMessage(true, false); if (message != null) { recordMessage(message); final Script script = (Script) publishScript.getSelectedToggle().getUserData(); if (script == null) { logger.debug("Publishing with no script"); // This requires a proper byte[] to be passed, to be sure the encoding/format is not broken connection.publish(message.getTopic(), message.getRawMessage().getPayload(), message.getQoS(), message.isRetained()); recordPublicationTopic(message.getTopic()); } else { logger.debug("Publishing with '{}' script", script.getName()); // Publish with script scriptManager.runScriptFileWithMessage(script, message); } } } /** * Records the given message on the list of 'recent' messages. * * @param message The message to record */ private void recordMessage(final BaseMqttMessage message) { // If the message is the same as previous one, remove the old one if (recentMessages.size() > 0 && message.getTopic().equals(recentMessages.get(0).getTopic()) && message.getPayload().equals(recentMessages.get(0).getPayload())) { recentMessages.remove(0); } recentMessages.add(0, message); while (recentMessages.size() > MAX_RECENT_MESSAGES) { recentMessages.remove(MAX_RECENT_MESSAGES); } refreshRecentMessages(); } /** * Refreshes the list of recent messages shown in the publish button's context menu. */ private void refreshRecentMessages() { // Remove all elements while (recentMessagesMenu.getItems().size() > 0) { recentMessagesMenu.getItems().remove(0); } while (saveRecentMessagesMenu.getItems().size() > 0) { saveRecentMessagesMenu.getItems().remove(0); } // Add all elements for (final BaseMqttMessage message : recentMessages) { final String topic = message.getTopic(); final String payload = message.getPayload().length() > 10 ? message.getPayload().substring(0, 10) + "..." : message.getPayload(); final String time = TimeUtils.DATE_WITH_SECONDS_SDF.format(message.getDate()); final String messageText = "Topic = '" + topic + "', payload = '" + payload + "', published at " + time; final MenuItem recentMessageItem = new MenuItem(messageText); recentMessageItem.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent event) { displayMessage(message); } }); recentMessagesMenu.getItems().add(recentMessageItem); final MenuItem saveMessageItem = new MenuItem(messageText); saveMessageItem.setOnAction(new EventHandler<ActionEvent>() { @Override public void handle(ActionEvent event) { saveAsScript(message); } }); saveRecentMessagesMenu.getItems().add(saveMessageItem); } recentMessagesMenu.setDisable(recentMessagesMenu.getItems().size() == 0); saveRecentMessagesMenu.setDisable(saveRecentMessagesMenu.getItems().size() == 0); } @FXML private void saveCurrentAsScript() { final BaseMqttMessage message = readMessage(true, false); if (message != null) { saveAsScript(message); } } private void saveAsScript(final BaseMqttMessage message) { boolean valid = false; while (!valid) { final Optional<String> response = DialogFactory.createInputDialog( pane.getScene().getWindow(), "Enter a name for your message-based script", "Script name (without .js)"); logger.info("Script name response = " + response); if (response.isPresent()) { final String scriptName = response.get(); final String configuredDirectory = connection.getProperties().getConfiguredProperties().getPublicationScripts(); final String directory = InteractiveScriptManager.getScriptDirectoryForConnection(configuredDirectory); final File scriptFile = new File(directory + scriptName + MqttScriptManager.SCRIPT_EXTENSION); final Script script = scriptManager.getScriptObjectFromName( Script.getScriptIdFromFile(scriptFile)); if (script != null) { Optional<ButtonType> duplicateNameResponse = DialogFactory.createQuestionDialog("Script name already exists", "Script with name \"" + scriptName + "\" already exists in your script folder (" + directory + "). Do you want to override it?"); if (duplicateNameResponse.get() == ButtonType.NO) { continue; } else if (duplicateNameResponse.get() == ButtonType.CANCEL) { break; } } createScriptFromMessage(scriptFile , configuredDirectory, message); break; } else { break; } } } private void createScriptFromMessage(final File scriptFile, final String configuredDirectory, final BaseMqttMessage message) { final StringBuffer scriptText = new StringBuffer(); scriptText.append("mqttspy.publish(\""); scriptText.append(message.getTopic()); scriptText.append("\", \""); // TODO: any conversions needed here if this is not plain text? scriptText.append(message.getPayload()); scriptText.append("\", "); scriptText.append(message.getQoS()); scriptText.append(", "); scriptText.append(message.isRetained()); scriptText.append(");"); try { final String templateFilename = "/samples/template-script.js"; final String template = FileUtils.loadFileByNameAsString(templateFilename); final String script = template.replace( "mqttspy.publish(\"topic\", \"payload\");", scriptText.toString()); logger.info("Writing file to " + scriptFile.getAbsolutePath()); FileUtils.writeToFile(scriptFile, script); scriptManager.addScripts(configuredDirectory, ScriptTypeEnum.PUBLICATION); // TODO: move this to script manager? eventBus.publish(new ScriptListChangeEvent(connection)); } catch (IOException e) { logger.error("Cannot create the script file at " + scriptFile.getAbsolutePath(), e); } } /** * Restores message from the key event. * * @param keyEvent The generated key event * @param keyNumber The key number */ private void restoreFromKeypress(final KeyEvent keyEvent, final int keyNumber) { if (keyEvent.isAltDown()) { // 1 means first message (most recent); 2 is second, etc.; 0 is the 10th (the oldest) final int arrayIndex = (keyNumber > 0 ? keyNumber : MAX_RECENT_MESSAGES) - 1; if (arrayIndex < recentMessages.size()) { displayMessage(recentMessages.get(arrayIndex)); } keyEvent.consume(); } } private void showAndLogHexError() { logger.error("Cannot convert " + publicationData.getText() + " to plain text"); DialogFactory.createErrorDialog("Invalid hex format", "Provided text is not a valid hex string."); } public void setConnection(MqttAsyncConnection connection) { this.connection = connection; } public void clearTopics() { publicationTopics.clear(); } public ComboBox<String> getPublicationTopicText() { return publicationTopicText; } public ChoiceBox<String> getPublicationQosChoice() { return publicationQosChoice; } public StyleClassedTextArea getPublicationData() { return publicationData; } public CheckBox getRetainedBox() { return retainedBox; } public void setScriptManager(final InteractiveScriptManager scriptManager) { this.scriptManager = scriptManager; } public void hidePublishButton() { this.publishButton.setVisible(false); } @Override public TitledPane getTitledPane() { return pane; } @Override public void setTitledPane(TitledPane pane) { this.pane = pane; } @Override public void updatePane(PaneVisibilityStatus status) { if (PaneVisibilityStatus.ATTACHED.equals(status)) { settingsButton.setVisible(true); } else { settingsButton.setVisible(false); } } public void setEventBus(final IKBus eventBus) { this.eventBus = eventBus; } @Override public Label getTitleLabel() { return titleLabel; } }