package lighthouse.subwindows; import com.google.common.io.*; import com.google.protobuf.*; import de.jensd.fx.fontawesome.*; import javafx.application.*; import javafx.beans.binding.*; import javafx.beans.value.*; import javafx.embed.swing.*; import javafx.event.*; import javafx.fxml.*; import javafx.scene.*; import javafx.scene.canvas.*; import javafx.scene.control.*; import javafx.scene.effect.*; import javafx.scene.image.*; import javafx.scene.input.*; import javafx.scene.layout.*; import javafx.stage.*; import lighthouse.*; import lighthouse.controls.*; import lighthouse.files.*; import lighthouse.model.*; import lighthouse.protocol.*; import lighthouse.utils.*; import org.bitcoinj.core.*; import org.controlsfx.control.*; import org.pegdown.*; import org.pegdown.ast.*; import org.slf4j.*; import javax.imageio.*; import java.awt.image.*; import java.io.*; import java.net.*; import java.nio.file.Files; import java.nio.file.*; import static lighthouse.protocol.LHUtils.*; import static lighthouse.utils.GuiUtils.*; import static lighthouse.utils.I18nUtil.*; /** * A window that lets you create a new project or edit an existing one. */ public class EditProjectWindow { private static final Logger log = LoggerFactory.getLogger(EditProjectWindow.class); private static final String COVERPHOTO_SITE = "coverphotofinder.com"; @FXML BorderPane rootPane; @FXML Label coverPhotoSiteLink; @FXML Label coverImageLabel; @FXML ImageView coverImageView; @FXML TextField addressEdit; @FXML TextField goalAmountEdit; @FXML TextField titleEdit; @FXML TextField emailEdit; @FXML TextField minPledgeEdit; @FXML TextArea descriptionEdit; @FXML Button nextButton; @FXML Pane createPane; @FXML Label descriptionHelpButton; @FXML Label previewTextLabel; @FXML Label emailAddressLabel; private PopOver maxPledgesPopOver; public Main.OverlayUI<InnerWindow> overlayUI; private ProjectModel model; private boolean editing; public static void openForCreate() { ProjectModel projectModel = new ProjectModel(Main.wallet); // Pick a random server as the default suggestion, to load balance across them more effectively. projectModel.serverName.set(ServerList.INSTANCE.pickRandom().getHostName()); // By default. open(projectModel, tr("Create new project"), false); } public static void openForCreate(ProjectModel project) { open(project, tr("Create new project"), false); } public static void openForEdit(Project project) { open(new ProjectModel(project), tr("Edit project"), true); } public static void openForEdit(ProjectModel project) { open(project, tr("Edit project"), true); } private static void open(ProjectModel project, String title, boolean editing) { Main.OverlayUI<EditProjectWindow> ui = Main.instance.overlayUI("subwindows/add_edit_project.fxml", title); ui.controller.setupFor(project, editing); } private void setupFor(ProjectModel model, boolean editing) { this.model = model; this.editing = editing; setupMarkdownPreviewLink(); // Copy data from model. addressEdit.setText(model.address.get()); titleEdit.setText(model.title.get()); emailEdit.setText(model.email.get()); if (emailEdit.getText().isEmpty()) { String savedContact = Main.instance.prefs.getContactAddress(); if (savedContact != null) emailEdit.setText(savedContact); } descriptionEdit.setText(model.memo.get()); Coin goalCoin = Coin.valueOf(model.goalAmount.get()); if (goalCoin.value != 1) { // 1 satoshi is sentinel value meaning new project. goalAmountEdit.setText(goalCoin.toPlainString()); } if (editing) minPledgeEdit.setText(model.getMinPledgeAmount().toPlainString()); else minPledgeEdit.setPromptText(model.getMinPledgeAmount().toPlainString()); if (model.image.get() == null) { setupDefaultCoverImage(); } else { InputStream stream = model.image.get().newInput(); coverImageView.setImage(new Image(stream)); uncheck(stream::close); } // Bind UI back to model. this.model.title.bind(titleEdit.textProperty()); this.model.email.bind(emailEdit.textProperty()); this.model.memo.bind(descriptionEdit.textProperty()); coverPhotoSiteLink.setText(COVERPHOTO_SITE); ValidationLink goalValid = new ValidationLink(goalAmountEdit, str -> !LHUtils.didThrow(() -> valueOrThrow(str))); goalAmountEdit.textProperty().addListener((obj, prev, cur) -> { if (goalValid.isValid.get()) this.model.goalAmount.set(valueOrThrow(cur).value); }); // Figure out the smallest pledge that is allowed based on the goal divided by number of inputs we can have. model.minPledgeAmountProperty().addListener(o -> { minPledgeEdit.setPromptText(model.getMinPledgeAmount().toPlainString()); }); ValidationLink minPledgeValue = new ValidationLink(minPledgeEdit, str -> { if (str.isEmpty()) return true; // default is used Coin coin = valueOrNull(str); if (coin == null) return false; Coin amount = model.getMinPledgeAmount(); // If min pledge == suggested amount it's ok, or if it's between min amount and goal. return coin.equals(amount) || (coin.isGreaterThan(amount) && coin.isLessThan(Coin.valueOf(this.model.goalAmount.get()))); }); minPledgeEdit.textProperty().addListener((obj, prev, cur) -> { if (minPledgeValue.isValid.get()) { if (cur.trim().equals("")) model.resetMinPledgeAmount(); else model.setMinPledgeAmount(valueOrThrow(cur)); } }); ValidationLink addressValid = new ValidationLink(addressEdit, str -> !didThrow(() -> new Address(Main.params, str))); addressEdit.textProperty().addListener((obj, prev, cur) -> { if (addressValid.isValid.get()) this.model.address.set(cur); }); ValidationLink emailLink = new ValidationLink(emailEdit, str -> str.matches("^[\\w-_\\.+]*[\\w-_\\.]\\@([\\w]+\\.)+[\\w]+[\\w]$")); ValidationLink.autoDisableButton(nextButton, goalValid, new ValidationLink(titleEdit, str -> !str.isEmpty()), minPledgeValue, addressValid, emailLink); roundCorners(coverImageView, 10); // TRANS: %d = maximum number of pledges Label maxPledgesWarning = new Label(String.format(tr("You can collect a maximum of %d pledges, due to limits in the Bitcoin protocol."), ProjectModel.MAX_NUM_INPUTS)); maxPledgesWarning.setStyle("-fx-font-size: 12; -fx-padding: 10"); maxPledgesPopOver = new PopOver(maxPledgesWarning); maxPledgesPopOver.setDetachable(false); maxPledgesPopOver.setArrowLocation(PopOver.ArrowLocation.BOTTOM_CENTER); minPledgeEdit.focusedProperty().addListener(o -> { if (minPledgeEdit.isFocused()) maxPledgesPopOver.show(minPledgeEdit); else maxPledgesPopOver.hide(); }); } // Called by FXMLLoader. public void initialize() { // TODO: This fixed value won't work properly with internationalization. rootPane.setPrefWidth(618); rootPane.prefHeightProperty().bind(Main.instance.scene.heightProperty().multiply(0.87)); descriptionHelpButton.setText(""); AwesomeDude.setIcon(descriptionHelpButton, AwesomeIcon.QUESTION_CIRCLE); } private void setupDefaultCoverImage() { // The default image is nice, so a lot of people (including possibly me) will be lazy and not change it. To // keep things interesting we randomly recolour the image here. try { ColorAdjust colorAdjust = new ColorAdjust(); double randomHueAdjust = Math.random() * 2 - 1.0; colorAdjust.setHue(randomHueAdjust); // Draw into a canvas and then apply the effect, because if we snapshotted the image view, we'd end up // with the rounded corners which we don't want. Image image = new Image(getResource("default-cover-image.png").openStream()); Canvas canvas = new Canvas(image.getWidth(), image.getHeight()); GraphicsContext gc = canvas.getGraphicsContext2D(); gc.drawImage(image, 0, 0); gc.applyEffect(colorAdjust); WritableImage colouredImage = new WritableImage((int) image.getWidth(), (int) image.getHeight()); canvas.snapshot(new SnapshotParameters(), colouredImage); coverImageView.setImage(colouredImage); // Convert to a PNG and store in the project model. ImageIO.setUseCache(false); ByteArrayOutputStream baos = new ByteArrayOutputStream(); ImageIO.write(SwingFXUtils.fromFXImage(colouredImage, null), "png", baos); model.image.set(ByteString.copyFrom(baos.toByteArray())); } catch (IOException e) { throw new RuntimeException(e); } } @FXML public void nextClicked(ActionEvent event) { // Hack for accelerator crash. Platform.runLater(() -> { // Quick check that they haven't duplicated the title as otherwise that results in file name clashes // We could add (2) after the file name or whatever to avoid this, but multiple different projects with // the same title would be confusing anyway so just forbid it. if (!editing && Files.exists(AppDirectory.dir().resolve(Project.getSuggestedFileName(model.title.get())))) { informationalAlert(tr("Title conflict"), tr("You already have a project with that title. Please choose another. If you are trying to create a " + "different version, consider putting the date or a number in the title so people can distinguish them.")); return; } Main.instance.prefs.setContactAddress(model.email.get()); AddProjectTypeWindow.open(model, editing); }); } @FXML public void imageSelectorClicked(MouseEvent event) { log.info("Image selector clicked"); FileChooser chooser = new FileChooser(); chooser.setTitle(tr("Select an image file")); chooser.getExtensionFilters().setAll(new FileChooser.ExtensionFilter(tr("Images (JPG/PNG/GIF)"), "*.jpg", "*.jpeg", "*.png", "*.gif")); platformFiddleChooser(chooser); Path prevPath = Main.instance.prefs.getCoverPhotoFolder(); if (prevPath != null) chooser.setInitialDirectory(prevPath.toFile()); File result = chooser.showOpenDialog(Main.instance.mainStage); if (result == null) return; Main.instance.prefs.setCoverPhotoFolder(result.toPath().getParent()); setImageTo(unchecked(() -> result.toURI().toURL())); } private void setImageTo(URL result) { try { log.info("Setting image to {}", result); if (result.getProtocol().startsWith("http")) { // Also catch https final String oldLabel = coverImageLabel.getText(); final DownloadProgress task = new DownloadProgress(result); task.setOnSucceeded(ev -> { log.info("Image downloaded succeeded"); ByteString bytes = task.getValue(); coverImageLabel.setGraphic(null); coverImageLabel.setText(oldLabel); setImageTo(bytes); }); task.setOnFailed(ev -> { informationalAlert(tr("Image load failed"), // TRANS: %s = error message tr("Could not download the image from the remote server: %s"), task.getException().getLocalizedMessage()); coverImageLabel.setGraphic(null); coverImageLabel.setText(oldLabel); }); ProgressIndicator indicator = new ProgressIndicator(); indicator.progressProperty().bind(task.progressProperty()); indicator.setPrefHeight(50); indicator.setPrefWidth(50); coverImageLabel.setGraphic(indicator); coverImageLabel.setText(""); Thread download = new Thread(task); // TRANS: %s = image URL download.setName(String.format(tr("Download of %s"), result)); download.setDaemon(true); download.start(); } else { // Load in a blocking fashion. byte[] bits = ByteStreams.toByteArray(result.openStream()); if (bits.length > 1024 * 1024 * 5) { informationalAlert(tr("Image too large"), tr("Please make sure your image is smaller than 5mb, any larger is excessive.")); return; } final ByteString bytes = ByteString.copyFrom(bits); setImageTo(bytes); } } catch (Exception e) { log.error("Failed to load image", e); informationalAlert(tr("Failed to load image"), "%s", e.getLocalizedMessage()); } } private void setImageTo(ByteString bytes) { coverImageView.setEffect(null); // Force the size here at load time so we get a smaller version of the image pixels we can store in the project // file. This prevents massive image files from bloating things up. final Image image = new Image(bytes.newInput(), Project.COVER_IMAGE_WIDTH, Project.COVER_IMAGE_HEIGHT, true, true); Exception exception = image.getException(); if (exception == null) { try { ByteArrayOutputStream baos = new ByteArrayOutputStream(); BufferedImage im = SwingFXUtils.fromFXImage(image, null); // Force to JPEG as normally this results in smaller outputs, unless we are running on 8u20 which has // a bug that results in corrupted JPEGs. In this case we write out PNGs, but that's very bloated for // photos and makes bigger project files. String ver = System.getProperty("java.version"); if (ver.equals("1.8.0_20") || ver.equals("1.8.0_31")) ImageIO.write(im, "png", baos); else ImageIO.write(im, "jpeg", baos); byte[] bits = baos.toByteArray(); model.image.set(ByteString.copyFrom(bits)); coverImageView.setImage(image); } catch (IOException e) { exception = e; } } if (exception != null) log.error("Could not load image", exception); } public void imageSelectorDragOver(DragEvent event) { Dragboard dragboard = event.getDragboard(); if (dragboard.getFiles().size() == 1) { final String name = dragboard.getFiles().get(0).toString().toLowerCase(); if (name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png")) { event.acceptTransferModes(TransferMode.COPY_OR_MOVE); return; } } if (dragboard.getUrl() != null) { // We accept all URLs and filter out the non-image ones later. event.acceptTransferModes(TransferMode.COPY_OR_MOVE); } } @FXML public void imageSelectorDropped(DragEvent event) { log.info("Drop: {}", event); if (event.getDragboard().getFiles().size() == 1) { setImageTo(unchecked(() -> event.getDragboard().getFiles().get(0).toURI().toURL())); } else if (event.getDragboard().getUrl() != null) { setImageTo(unchecked(() -> new URL(event.getDragboard().getUrl()))); } } @FXML public void openCoverPhotoFinder(MouseEvent event) { log.info("cover photo URL clicked"); Main.instance.getHostServices().showDocument(String.format("http://%s/", COVERPHOTO_SITE)); event.consume(); } @FXML public void cancelClicked(ActionEvent event) { overlayUI.done(); } @FXML public void showMarkdownHelp(MouseEvent event) { Main.instance.getHostServices().showDocument(tr("https://help.github.com/articles/markdown-basics/")); } private static final PegDownProcessor parser = new PegDownProcessor(100L /* max parse time msec */); private void setupMarkdownPreviewLink() { // Monitor the text and if it appears to use Markdown, show the preview link. Otherwise hide it. previewTextLabel.setMinHeight(0.0); ObservableBooleanValue linkHidden = Bindings.createBooleanBinding( () -> !isMarkdown(descriptionEdit.getText()), descriptionEdit.textProperty() ); NumberBinding height = Bindings.when(linkHidden).then(0.0).otherwise(25.0); animatedBind(previewTextLabel, previewTextLabel.minHeightProperty(), height); } private boolean isMarkdown(String text) { return isMarkdown(parser.parseMarkdown(text.toCharArray())); } private boolean isMarkdown(RootNode tree) { return MarkDownNode.countFormattingNodes(tree) > 0; } @FXML public void onPreviewTextClicked(MouseEvent event) { log.info("Preview text clicked"); MarkDownNode.openPopup(descriptionEdit.textProperty(), (url) -> Main.instance.getHostServices().showDocument(url)); } }