import java.io.*;
import java.nio.file.*;
import java.nio.charset.*;
import java.util.*;
import java.util.function.*;
import java.util.Map.*;
import java.util.AbstractMap.*;
import java.text.*;
import java.time.*;

import javafx.application.*;
import javafx.stage.*;
import javafx.scene.*;
import javafx.scene.image.*;
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.text.*;
import javafx.scene.input.*;
import javafx.scene.canvas.*;
import javafx.scene.paint.*;
import javafx.collections.*;
import javafx.collections.transformation.*;
import javafx.beans.*;
import javafx.beans.property.*;
import javafx.geometry.*;
import javafx.event.*;

public class VCardEdit extends Application {

  TableView<VCard> tableView;
  ObservableList<VCard> data = null;
  ObservableList<String> oblCharset = null;
  boolean nf = false;
  SortedList<VCard> sortedList;
  TableRow<VCard> currentRow = null;
  ContextMenu contextMenu = new ContextMenu();
  FileChooser.ExtensionFilter efVcf = new FileChooser.ExtensionFilter("vCard (*.vcf, *.vcard)", "*.vcf", "*.vcard");
  FileChooser.ExtensionFilter efImgs = new FileChooser.ExtensionFilter("Supported image formats (*.jpg, *.jpeg, *.png, *.gif)", "*.jpg", "*.jpeg", "*.png", "*.gif");
  FileChooser.ExtensionFilter efAll = new FileChooser.ExtensionFilter("All files (*.*)", "*.*");
  BooleanProperty bprModified = new SimpleBooleanProperty(false);
  StringProperty sprFNane = new SimpleStringProperty("");
  Charset charset = Charset.defaultCharset();
  int csSpecify = 0;
  Label lsb1, lsb2;
  Stage stage;
  File appRootPath;

  @Override
  public void start(Stage stage) {
    this.stage = stage;
    try {
      String sarp = getClass().getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
      if (getClass().getResource("/" + getClass().getName() + ".class").toString().startsWith("jar:")) {
        sarp = sarp.substring(0, sarp.lastIndexOf("/") + 1);
      }
      appRootPath = new File(sarp);
    } catch (Exception ex) { appRootPath = new File(""); }
    BorderPane layout = new BorderPane();
    stage.setScene(new Scene(layout, 700, 400));
    lsb1 = new Label(""); lsb1.setPrefWidth(150);
    lsb2 = new Label(charset.name());
    MenuBar menuBar = new MenuBar();
    Menu miFile = new Menu("File");
    menuBar.setBackground(Background.EMPTY);
    MenuItem miFileNew = new MenuItem("New");
    miFileNew.setOnAction(ae -> { if (confirmSave()) { data.clear(); sprFNane.set(""); bprModified.set(false); nf = false; } });
    MenuItem miFileOpen = new MenuItem("Open...");
    miFileOpen.setOnAction(ae -> { if (confirmSave()) load(null, true); });
    MenuItem miFileAppendFrom = new MenuItem("Append from...");
    miFileAppendFrom.setOnAction(ae -> { load(null, false); });
    MenuItem miFileSave = new MenuItem("Save");
    miFileSave.setOnAction(ae -> { save(false); });
    MenuItem miFileSaveAs = new MenuItem("Save as...");
    miFileSaveAs.setOnAction(ae -> { save(true); });
    MenuItem miFileOptions = new MenuItem("Options...");
    miFileOptions.setOnAction(ae -> { options(); });
    MenuItem miFileExit = new MenuItem("Exit");
    miFileExit.setOnAction(ae -> { if (confirmSave()) { Platform.exit(); System.exit(0); } });
    miFile.getItems().addAll(miFileNew, miFileOpen, miFileAppendFrom, miFileSave, miFileSaveAs, miFileOptions, new SeparatorMenuItem(), miFileExit);
    menuBar.getMenus().addAll(miFile);
    miFile.setOnShowing(e -> { miFileSave.setDisable(!bprModified.get() || nf || sprFNane.get().isEmpty()); });
    BorderPane topLayout = new BorderPane();
    topLayout.setLeft(menuBar);
    topLayout.setRight(search());
    layout.setTop(topLayout);
    vCardTable();
    layout.setCenter(tableView);
    HBox hBox = new HBox(lsb1, lsb2); hBox.setPadding(new Insets(2, 0, 2, 6));
    hBox.setOnMouseClicked(me -> {
      if (me.getButton() == MouseButton.PRIMARY && me.getClickCount() == 2) options();
    });
    layout.setBottom(hBox);
    stage.setOnCloseRequest(e -> { if (!confirmSave()) e.consume(); });
    stage.show();
    Platform.runLater(() -> {
      List<String> p = getParameters().getRaw();
      if (!p.isEmpty()) {
        File pFile = new File(p.get(0)).getAbsoluteFile();
        try { pFile = pFile.getCanonicalFile(); } catch (Exception ex) { }
        if (pFile.isFile()) load(pFile, true);  // new Thread(() -> { load(pFile, true); }).start();
        else alert(Alert.AlertType.CONFIRMATION, "File " + pFile.getName() + " not found.", stage, ButtonType.OK);
      }
      tableView.requestFocus();
    });
  }

  boolean confirmSave() {
    if (bprModified.get()) {
      ButtonType bttp = alert(Alert.AlertType.CONFIRMATION, "File is changed. Save?", stage, ButtonType.YES, ButtonType.NO, ButtonType.CANCEL);
      if (bttp == null || bttp == ButtonType.CANCEL) return false;
      if (bttp == ButtonType.NO) return true;
      return save(false);
    }
    return true;
  }

  boolean load(File openFile, boolean v) {
    if (openFile == null) {
      FileChooser fileChooser = new FileChooser();
      fileChooser.setTitle("Open (" + charset.name()+")");
      fileChooser.getExtensionFilters().addAll(efVcf, efAll);
      fileChooser.setInitialDirectory(appRootPath.isDirectory() ? appRootPath : null);
      openFile = fileChooser.showOpenDialog(stage);
      if (openFile == null) return false;
    }
    appRootPath = openFile.getParentFile();
    try (InputStream in = Files.newInputStream(openFile.toPath())) {
      if (v) data.clear();
      data.addAll(VCard.read(in, charset));
      tableView.refresh();
      tableView.getSelectionModel().selectFirst();
      if (v) { sprFNane.set(openFile.getName()); nf = true; }
      bprModified.set(!v);
      return true;
    } catch (Exception ex) { alert(Alert.AlertType.ERROR, ex.toString(), stage, ButtonType.OK); }
    return false;
  }

  boolean save(boolean v) {
    File saveFile;
    if (v || nf || sprFNane.get().isEmpty() || !appRootPath.isDirectory()) {
      FileChooser fileChooser = new FileChooser();
      fileChooser.setTitle("Save as (" + charset.name()+")");
      fileChooser.getExtensionFilters().addAll(efAll, efVcf);
      fileChooser.setInitialDirectory(appRootPath.isDirectory() ? appRootPath : null);
      fileChooser.setInitialFileName(sprFNane.get().isEmpty() ? "vCard.vcf" : sprFNane.get());
      saveFile = fileChooser.showSaveDialog(stage);
      if (saveFile == null) return false;
      appRootPath = saveFile.getParentFile();
    } else saveFile = new File(appRootPath, sprFNane.get());
    try (OutputStream out = Files.newOutputStream(saveFile.toPath())) {
      VCard.write(out, sortedList, charset, csSpecify);
      bprModified.set(false);
      sprFNane.set(saveFile.getName());
      nf = false;
      return true;
    } catch (Exception ex) { alert(Alert.AlertType.ERROR, ex.toString(), stage, ButtonType.OK); }
    return false;
  }

  void vCardTable() {
    InvalidationListener listener = obs -> { stage.setTitle((sprFNane.get().isEmpty() ? "New" : sprFNane.get()) + (bprModified.get() ? " *" : "")); };
    bprModified.addListener(listener);
    sprFNane.addListener(listener);
    listener.invalidated(null);
    Font fontBold = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, Font.getDefault().getSize() + 3);
    tableView = new TableView<>();
    tableView.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY);
    tableView.setPlaceholder(new Label(null));
    data = FXCollections.observableArrayList();
    sortedList = new SortedList<>(data);
    sortedList.comparatorProperty().bind(new SimpleObjectProperty<Comparator<VCard>>((t1, t2) -> t1.compareTo(t2)));
    tableView.setItems(sortedList);
    TableColumn<VCard, VCard> column = new TableColumn<>();
    column.setCellValueFactory(c -> new ReadOnlyObjectWrapper<VCard>(c.getValue()));
    column.setCellFactory(c -> new TableCell<VCard, VCard>() {
      Node emptyPhoto = crPict(null, 0);
      @Override
      public void updateItem(VCard item, boolean empty) {
        super.updateItem(item, empty);
        if (empty || item == null) setGraphic(null);
        else {
          if (item.obj == null) {
            Image img = crImg(item.photo);
            item.obj = new SimpleEntry<Image, Node>(img, (img == null ? null : crPict(img, 0)));
          }
          @SuppressWarnings("unchecked") Entry<Image, Node> ent = (Entry<Image, Node>) item.obj;
          Node pict = (ent.getKey() == null ? emptyPhoto : ent.getValue());
          GridPane gridPane = new GridPane();
          gridPane.setHgap(20);
          gridPane.setPadding(new Insets(1, 10, 1, 10));
          gridPane.add(pict, 0, 0, 1, 4);
          Label l = new Label(item.fname); l.setMaxWidth(400); l.setFont(fontBold);
          gridPane.add(l, 1, 0, 3, 1);
          String[] st = new String[] { item.name[0], item.name[1], item.name[2] };
          for (int i = 0; i < 3; i++) {
            Label label;
            label = new Label(st[i]); label.setMinWidth(130); label.setMaxWidth(130);
            gridPane.add(label, 1, i + 1);
            String s;
            s = (item.tel.size() > i ? (String)item.tel.get(i).value : "");
            label = new Label(s); label.setMinWidth(150); label.setMaxWidth(150);
            gridPane.add(label, 2, i + 1);
            s = (item.email.size() > i ? (String)item.email.get(i).value : "");
            label = new Label(s); label.setMinWidth(200); label.setMaxWidth(200);
            gridPane.add(label, 3, i + 1);
          }
          setGraphic(gridPane);
        }
      }
    });
    tableView.getColumns().add(column);
    MenuItem miEdit = new MenuItem("Edit");
    miEdit.setOnAction(ae -> { edit(0); });
    MenuItem miView = new MenuItem("View");
    miView.setOnAction(ae -> { view(); });
    MenuItem miIns = new MenuItem("Insert");
    miIns.setOnAction(ae -> { edit(1); });
    MenuItem miDel = new MenuItem("Delete");
    miDel.setOnAction(ae -> { delete(); });
    contextMenu.getItems().setAll(miEdit, miView, miIns, new SeparatorMenuItem(), miDel);
    tableView.setRowFactory(tv -> {
      TableRow<VCard> row = new TableRow<>();
      row.setOnMouseClicked(me -> { currentRow = row; });
      return row;
    });
    tableView.setOnMouseClicked(me -> {
      if (contextMenu.isShowing()) contextMenu.hide();
      boolean b = (currentRow == null || currentRow.isEmpty());
      if (me.getButton() == MouseButton.PRIMARY && me.getClickCount() == 2 && !b) {
        edit(0);
      } else if (me.getButton() == MouseButton.SECONDARY) {
        miEdit.setDisable(b);
        miView.setDisable(b);
        miDel.setDisable(b);
        contextMenu.show((Node)me.getSource(), me.getScreenX(), me.getScreenY());
      }
    });
    tableView.setOnKeyPressed(ke -> {
      if (ke.getCode() == KeyCode.ENTER) edit(0);
      //else if (ke.getCode() == KeyCode.F3) { int index = tableView.getSelectionModel().getSelectedIndex(); if (index >= 0) System.out.println(sortedList.get(index).toString()); }
      else if (ke.getCode() == KeyCode.F5) view();
      else if (ke.getCode() == KeyCode.INSERT) edit(1);
      else if (ke.getCode() == KeyCode.DELETE) delete();
    });

    tableView.getSelectionModel().selectedItemProperty().addListener((v, o, n) -> {
      int index = tableView.getSelectionModel().getSelectedIndex();
      lsb1.setText(index < 0 ? "" : (index + 1) + " / "+sortedList.size());
    });

    tableView.widthProperty().addListener((v, o, n) -> {
      Pane header = (Pane)tableView.lookup("TableHeaderRow");
      if (header != null && header.isVisible()) {
        header.setMaxHeight(0); header.setMinHeight(0); header.setPrefHeight(0);
        header.setVisible(false); header.setManaged(false);
      }
    });
    tableView.getSelectionModel().selectFirst();
  }

  Image crImg(byte[] bytes) {
    if (bytes != null && bytes.length != 0) {
      Image img = new Image(new ByteArrayInputStream(bytes));
      if (img.getWidth() > 0 && img.getHeight() > 0) return img;
    }
    return null;
  }

  Node crPict(Image image, int vr) {
    int size = (vr == 0 ? 50 : 200);
    if (image == null) {
      Canvas canvas = new Canvas(size, size);
      GraphicsContext gc = canvas.getGraphicsContext2D();
      gc.setFill(Color.LIGHTGREY);
      gc.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
      double scale = size / 10;
      gc.scale(scale, scale);
      gc.setFill(Color.GRAY);
      gc.appendSVGPath("M3 3A1 1 0 007 3A1 1 0 003 3");
      gc.appendSVGPath("M1 9A2 4 0 013 5A4 6 0 007 5A2 4 0 019 9Z");
      gc.fill();
      return canvas;
    }
    ImageView imageView = new ImageView(image);
    imageView.setPreserveRatio(true);
    imageView.setFitHeight(Math.min(imageView.getImage().getHeight(), size));
    imageView.setFitWidth(Math.min(imageView.getImage().getWidth(), size));
    StackPane stackPane = new StackPane(imageView);
    stackPane.setMinSize(size, size);
    return stackPane;
  }

  void edit(int vr) {
    int index = tableView.getSelectionModel().getSelectedIndex(), dindex = -1;
    VCard vc;
    if (vr > 0 || index >= 0) {
      if (vr > 0) vc = new VCard();
      else {
        dindex = sortedList.getSourceIndex(index);
        vc = data.get(dindex);
      }
      Dialog<ButtonType> dialog = new Dialog<>();
      dialog.initOwner(stage);
      dialog.initStyle(StageStyle.UTILITY);
      dialog.setTitle(vr > 0 ? "New" : vc.fname);
      dialog.setHeaderText(null);
      ObservableList<String> olfname = FXCollections.observableArrayList();
      ComboBox<String> cbfname = new ComboBox<>(olfname); cbfname.setPrefWidth(200);
      cbfname.setValue(vc.fname);
      cbfname.setEditable(true);
      LocalDate localDate = (vc.bday == null ? null : vc.bday.toInstant().atZone(ZoneId.systemDefault()).toLocalDate());
      DatePicker dpbday = new DatePicker(localDate); dpbday.setPrefWidth(200);
      dpbday.focusedProperty().addListener((v, o, n) -> {
        if (!n) dpbday.getOnShowing().handle(new Event(Event.ANY));
      });
      dpbday.setOnShowing(e -> {
        try {
         dpbday.setValue(dpbday.getConverter().fromString(dpbday.getEditor().getText()));
        } catch (Exception ex) { dpbday.setValue(null); dpbday.getEditor().setText(""); }
      });
      TextField tforg = new TextField(vc.org);
      TextField tftitle = new TextField(vc.title);
      Platform.runLater(() -> cbfname.requestFocus());
      GridPane gp1 = new GridPane();
      gp1.setHgap(20);
      gp1.setVgap(6);
      GridPane gp2 = new GridPane();
      gp2.setHgap(10);
      gp2.setVgap(6);
      Label l1, l2, l3, l4;
      gp2.addRow(0, l1 = new Label("Full name:"), cbfname); GridPane.setHalignment(l1, HPos.RIGHT);
      int r = 1;
      String[] t = { "Family name", "Given name", "Additional name", "Honorific prefixe", "Honorific suffixe" };
      if (vc.name == null || vc.name.length != t.length) vc.name = new String[t.length];
      TextField[] tf = new TextField[t.length];
      for (int i = 0; i < t.length; i++) {
        Label l = new Label(t[i] + ":");
        gp2.addRow(r++, l, tf[i] = new TextField(vc.name[i])); GridPane.setHalignment(l, HPos.RIGHT);
      }
      gp2.addRow(r++, l2 = new Label("Date of birth:"), dpbday); GridPane.setHalignment(l2, HPos.RIGHT);
      gp2.addRow(r++, l3 = new Label("Organization:"), tforg); GridPane.setHalignment(l3, HPos.RIGHT);
      gp2.addRow(r,   l4 = new Label("Job title:"), tftitle); GridPane.setHalignment(l4, HPos.RIGHT);
      cbfname.setOnShowing(e -> {
        cbfname.getItems().clear();
        boolean b1 = !tf[0].getText().isBlank(), b2 = !tf[1].getText().isBlank();
        if (b1) cbfname.getItems().addAll(tf[0].getText());
        if (b2) cbfname.getItems().addAll(tf[1].getText());
        if (b1 && b2) cbfname.getItems().addAll(tf[0].getText() + " " + tf[1].getText(), tf[1].getText() + " " + tf[0].getText());
      });
      gp1.add(gp2, 0, 0); gp2.setAlignment(Pos.TOP_RIGHT);
      ObservableList<VCard.Itm> oltel = FXCollections.observableArrayList(vc.tel);
      int[] aiptel = { vc.ptel };
      gp1.add(listEd(new String[] { "Phone" }, "Phone numbers", oltel, aiptel, 0, dialog), 0, 1);
      ObservableList<VCard.Itm> olemail = FXCollections.observableArrayList(vc.email);
      int[] aipemail = { vc.pemail };
      gp1.add(listEd(new String[] { "E-mail" }, "E-mail addresses", olemail, aipemail, 1, dialog), 0, 2);
      ObservableList<VCard.Itm> olurl = FXCollections.observableArrayList(vc.url);
      int[] aipurl = { vc.purl };
      gp1.add(listEd(new String[] { "URL" }, "URL", olurl, aipurl, 1, dialog), 1, 2);
      Object[] obj = { vc.photo, VCard.imgf[vc.itphoto < 0 || vc.itphoto >= VCard.imgf.length ? 0 : vc.itphoto].toLowerCase() };
      gp1.add(imgEd(obj, dialog), 1, 0);
      ObservableList<VCard.Itm> oladr = FXCollections.observableArrayList(vc.adr);
      int[] aipadr = { vc.padr };
      String[] m = { "Post office box", "Extended address (suite number)", "Street address",
                     "Locality (city)", "Region (state or province)", "Postal code", "Country" };
      gp1.add(listEd(m, "Physical address", oladr, aipadr, 2, dialog), 1, 1);
      TextArea taNote = new TextArea(vc.note); taNote.setPrefRowCount(2); taNote.setPrefWidth(250);
      HBox hb = new HBox(10, new Label("Note:"), taNote); hb.setAlignment(Pos.CENTER_RIGHT);
      gp1.add(hb, 0, 3);
      if (vc.rev != null) {
        TextField tfd = new TextField(DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM).format(vc.rev));
        tfd.setPrefWidth(300);
        tfd.setEditable(false); tfd.setFocusTraversable(false);
        VBox vb = new VBox(3, new Label("vCard last updated:"), tfd);
        vb.setAlignment(Pos.TOP_LEFT);
        gp1.add(vb, 1, 3);
      }
      dialog.getDialogPane().setContent(gp1);
      dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
      Optional<ButtonType> result = dialog.showAndWait();
      if(result.isPresent() && result.get() == ButtonType.OK) {
        bprModified.set(true);
        vc.fname = cbfname.getValue();
        for (int i = 0; i < t.length; i++) vc.name[i] = (tf[i].getText() == null ? "" : tf[i].getText());
        vc.tel.clear(); vc.tel.addAll(oltel);
        vc.email.clear(); vc.email.addAll(olemail);
        vc.url.clear(); vc.url.addAll(olurl);
        vc.adr.clear(); vc.adr.addAll(oladr);
        vc.ptel = aiptel[0];
        vc.pemail = aipemail[0];
        vc.purl = aipurl[0];
        vc.padr = aipadr[0];
        vc.bday = (dpbday.getValue() == null ? null : Date.from(dpbday.getValue().atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()));
        vc.org = tforg.getText();
        vc.title = tftitle.getText();
        vc.note = taNote.getText();
        vc.photo = (byte[]) obj[0];
        int i = (obj[1] == null ? -1 : Arrays.asList(VCard.imgf).indexOf(((String) obj[1]).toUpperCase()));
        vc.itphoto = (i < 0 ? 0 : i);
        vc.obj = null;
        vc.rev = new Date();
        if (vr > 0) data.add(vc);
        else data.set(dindex, vc);
        index = sortedList.indexOf(vc);
        tableView.getSelectionModel().select(index);
        tableView.scrollTo(index);
        tableView.refresh();
      }
    }
  }

  void view() {
    int index = tableView.getSelectionModel().getSelectedIndex();
    if (index >= 0) {
      VCard vc = sortedList.get(index);
      Dialog<ButtonType> dialog = new Dialog<>();
      dialog.initOwner(stage);
      dialog.initStyle(StageStyle.UTILITY);
      dialog.setResizable(true);
      dialog.setTitle(vc.fname);
      dialog.setHeaderText(null);
      ByteArrayOutputStream out = new ByteArrayOutputStream();
      try { vc.write(out, charset, csSpecify); } catch (Exception ex) { ex.printStackTrace(); }
      TextArea ta = new TextArea(new String(out.toByteArray(), charset));
      VBox.setVgrow(ta, Priority.ALWAYS);
      ta.setEditable(false);
      ta.setFont(Font.font("Monospaced", Font.getDefault().getSize() + 2));
      Button bt = new Button("Save");
      bt.setOnAction(ae -> {
        FileChooser fileChooser = new FileChooser();
        fileChooser.setTitle("Save (" + charset.name()+")");
        fileChooser.setInitialDirectory(appRootPath.isDirectory() ? appRootPath : null);
        fileChooser.setInitialFileName("vCard.vcf");
        File saveFile = fileChooser.showSaveDialog(dialog.getDialogPane().getScene().getWindow());
        if (saveFile != null) {
          appRootPath = saveFile.getParentFile();
          try {
            Files.write(saveFile.toPath(), out.toByteArray());
          } catch (Exception ex) { alert(Alert.AlertType.ERROR, ex.toString(), dialog.getDialogPane().getScene().getWindow(), ButtonType.OK); }
        }
      });
      HBox hb = new HBox(bt); hb.setPadding(new Insets(8));
      VBox vb = new VBox(hb, ta); vb.setPadding(Insets.EMPTY);
      dialog.getDialogPane().setContent(vb);
      dialog.getDialogPane().setPrefSize(800, 600);
      dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK);
      Platform.runLater(() -> ta.requestFocus());
      dialog.showAndWait();
    }
  }

  void delete() {
    int index = tableView.getSelectionModel().getSelectedIndex();
    if (index >= 0) { data.remove(sortedList.getSourceIndex(index)); bprModified.set(true); }
  }

  Pane imgEd(Object[] obj, Dialog<ButtonType> dlg) {
    Button bt1 = new Button("Load");    bt1.setMinWidth(70);
    Button bt2 = new Button("Remove");  bt2.setMinWidth(70);
    Button bt3 = new Button("Extract"); bt3.setMinWidth(70);
    NumberFormat nf = NumberFormat.getIntegerInstance();
    Label lpi = new Label();
    Image[] img = { crImg((byte[]) obj[0]) };
    HBox hb = new HBox(6, bt1, bt2, bt3); hb.setAlignment(Pos.CENTER);
    VBox vb = new VBox(6, new Region(), lpi, hb); vb.setAlignment(Pos.CENTER);
    Runnable rn = () -> {
      vb.getChildren().set(0, crPict(img[0], 1));
      lpi.setText(img[0] == null ? "" : (String) obj[1] + " / " + nf.format(img[0].getWidth()) + " x " + nf.format(img[0].getHeight()) + " / " + nf.format(((byte[]) obj[0]).length) + " Bt");
    };
    rn.run();
    bt1.setOnAction(e -> {
      FileChooser fileChooser = new FileChooser();
      fileChooser.getExtensionFilters().addAll(efImgs, efAll);
      fileChooser.setInitialDirectory(appRootPath.isDirectory() ? appRootPath : null);
      File openFile = fileChooser.showOpenDialog(dlg.getDialogPane().getScene().getWindow());
      if (openFile != null) {
        appRootPath = openFile.getParentFile();
        String name = openFile.getName();
        int i = name.lastIndexOf('.');
        String ext = (i < 0 ? "" : name.substring(i + 1).toLowerCase()), msg = null;
        if (i < 0 || efImgs.getExtensions().indexOf("*." + ext) < 0) msg = "Unknown format";
        else if (openFile.length() > 100_000L) msg = "File is too large";
        else {
          try {
            byte[] bytes = Files.readAllBytes(openFile.toPath());
            Image im = crImg(bytes);
            if (im == null) msg = "No image file";
            else {
              if (ext.equals("jpg")) ext = "jpeg";
              obj[0] = bytes; obj[1] = ext; img[0] = im;
              rn.run();
            }
          } catch (Exception ex) { msg = ex.toString(); }
        }
        if (msg != null) alert(Alert.AlertType.ERROR, msg, dlg.getDialogPane().getScene().getWindow(), ButtonType.OK);
      }
    });
    bt2.setOnAction(e -> {
      if (obj[0] != null) {
        obj[0] = obj[1] = img[0] = null;
        rn.run();
      }
    });
    bt3.setOnAction(e -> {
      if (obj[0] != null) {
        FileChooser fileChooser = new FileChooser();
        fileChooser.setInitialDirectory(appRootPath.isDirectory() ? appRootPath : null);
        String ext = ((String) obj[1]);
        fileChooser.setInitialFileName("image." + (ext.equals("jpeg") ? "jpg" : ext));
        File saveFile = fileChooser.showSaveDialog(dlg.getDialogPane().getScene().getWindow());
        if (saveFile != null) {
          appRootPath = saveFile.getParentFile();
          try {
            Files.write(saveFile.toPath(), (byte[]) obj[0]);
          } catch (Exception ex) { alert(Alert.AlertType.ERROR, ex.toString(), dlg.getDialogPane().getScene().getWindow(), ButtonType.OK); }
        }
      }
    });
    return vb;
  }

  ButtonType alert(Alert.AlertType type, String cont, Window window, ButtonType... bt) {
    Alert alert = new Alert(type, cont, bt);
    alert.initOwner(window);
    alert.initStyle(StageStyle.UTILITY);
    alert.setHeaderText(null);
    Optional<ButtonType> result = alert.showAndWait();
    return (result.isPresent() ? result.get() : null);
  }

  Node listEd(String[] tl1, String tl2, ObservableList<VCard.Itm> olist, int[] p, int vr, Dialog<ButtonType> dlg) {
    Button bt1 = new Button("Add");    bt1.setMinWidth(70);
    Button bt2 = new Button("Edit");   bt2.setMinWidth(70);
    Button bt3 = new Button("Remove"); bt3.setMinWidth(70);
    Button bt4 = new Button(null, shp(12, 12, "M0 7L6 0L12 7H8V12H4V7Z")); bt4.setMinWidth(32);
    Button bt5 = new Button(null, shp(12, 12, "M0 5L6 12L12 5H8V0H4V5Z")); bt5.setMinWidth(32);
    Font fontBold = Font.font(Font.getDefault().getFamily(), FontWeight.BOLD, Font.getDefault().getSize() + 1);
    Function<String[], String> jn = (t) -> {
      String s = Arrays.stream(t).reduce("", (x, y) -> x + (y == null || y.isBlank() ? "" : ", " + y.replace('\n', ' ')));
      return (s.length() < 2 ? "" : s.substring(2));
    };
    ListView<VCard.Itm> listView = new ListView<>(olist);
    listView.setCellFactory(c -> {
      ListCell<VCard.Itm> cell = new ListCell<>() {
        @Override
        public void updateItem(VCard.Itm item, boolean empty) {
          super.updateItem(item, empty);
          if (empty || item == null) setGraphic(null);
          else {
            Label l4 = new Label(item.note.replace('\n', ' '));
            Label l3 = new Label(p[0] == getIndex() ? "p" : "");
            if (vr == 2) {
              Label l1 = new Label(jn.apply((String[])item.value));  l1.setFont(fontBold); l1.setMinWidth(140); l1.setMaxWidth(140);
              Label l2 = new Label(VCard.tadr[(item.idl < 0 || item.idl >= VCard.tadr.length ? 0 : item.idl)]); l2.setMinWidth(40); l2.setMaxWidth(40);
              setGraphic(new VBox(new HBox(10, l1, l2, l3), l4));
            } else if (vr == 0) {
              Label l1 = new Label((String)item.value);  l1.setFont(fontBold); l1.setMinWidth(140); l1.setMaxWidth(140);
              Label l2 = new Label(VCard.ttel[(item.idl < 0 || item.idl >= VCard.ttel.length ? 0 : item.idl)]); l2.setMinWidth(40); l2.setMaxWidth(40);
              setGraphic(new VBox(new HBox(10, l1, l2, l3), l4));
            } else {  // vr == 1
              Label l1 = new Label((String)item.value);  l1.setFont(fontBold); l1.setMinWidth(190); l1.setMaxWidth(190);
              setGraphic(new VBox(new HBox(10, l1, l3), l4));
            }
          }
        }
      };
      cell.setOnMouseClicked(me -> {
        if (!cell.isEmpty() && me.getButton() == MouseButton.PRIMARY && me.getClickCount() == 2) bt2.fire();
      });
      return cell;
    });

    EventHandler<ActionEvent> ae1 = e -> {
      int index = listView.getSelectionModel().getSelectedIndex();
      if (e.getTarget() == bt1 || index >= 0) {
        if (e.getTarget() == bt1) index = -1;
        int ind;
        boolean b;
        String note;
        String[] m;
        if (index < 0) { Arrays.fill(m = new String[tl1.length], ""); ind = 0; note = ""; b = false; }
        else {
          if (vr == 2) {
            m = Arrays.copyOf((String[])olist.get(index).value, tl1.length);
          } else {
            m = new String[] { (String)olist.get(index).value };
          }
          ind = olist.get(index).idl;
          note = olist.get(index).note;
          b = (index == p[0]);
        }
        Dialog<ButtonType> dialog = new Dialog<>();
        dialog.initOwner(dlg.getDialogPane().getScene().getWindow());
        dialog.initStyle(StageStyle.UTILITY);
        dialog.setTitle(index < 0 ? "New" : jn.apply(m));
        dialog.setHeaderText(null);
        GridPane gridPane = new GridPane();
        gridPane.setHgap(10);
        gridPane.setVgap(6);
        TextInputControl[] tf = new TextInputControl[m.length];
        int r = 0;
        for (int i = 0; i < m.length; i++) {
          Label l = new Label(tl1[i] + ":");
          if (i == 2) { tf[i] = new TextArea(m[i]); ((TextArea)tf[i]).setPrefRowCount(2); }
          else tf[i] = new TextField(m[i]);
          gridPane.addRow(r++, l, tf[i]); GridPane.setHalignment(l, HPos.RIGHT); tf[i].setPrefWidth(200);
        }
        String[] t = (vr == 0 ? VCard.ttel : VCard.tadr);
        ComboBox<String> comboBox = new ComboBox<>(FXCollections.observableArrayList(t)); comboBox.setPrefWidth(200);
        comboBox.setValue(t[(ind < 0 || ind >= t.length ? 0 : ind)]);
        Label l1, l2, l3;
        if (vr != 1) {
          gridPane.addRow(r++, l1 = new Label("Type:"), comboBox); GridPane.setHalignment(l1, HPos.RIGHT);
        }
        CheckBox checkBox = new CheckBox();
        checkBox.setSelected(b);
        gridPane.addRow(r++, l2 = new Label("Preferred:"), checkBox); GridPane.setHalignment(l2, HPos.RIGHT);
        TextArea ta = new TextArea(note); ta.setPrefWidth(200); ta.setPrefRowCount(2);
        gridPane.addRow(r, l3 = new Label("Note:"), ta); GridPane.setHalignment(l3, HPos.RIGHT);
        dialog.getDialogPane().setContent(gridPane);
        Platform.runLater(() -> tf[0].requestFocus());
        dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
        Optional<ButtonType> result = dialog.showAndWait();
        if(result.isPresent() && result.get() == ButtonType.OK) {
          for (int i = 0; i < m.length; i++) m[i] = (tf[i].getText() == null ? "" : tf[i].getText());
          if (index < 0) { olist.add(null); index = olist.size() - 1; }
          int intg = (vr == 1 ? -1 : comboBox.getItems().indexOf(comboBox.getValue()));
          VCard.Itm itm;
          if (vr == 2) itm = new VCard.Itm("", intg, m, ta.getText());
          else         itm = new VCard.Itm("", intg, tf[0].getText(), ta.getText());
          olist.set(index, itm);
          if (checkBox.isSelected()) p[0] = index;
          else if (index == p[0]) p[0] = -1;
          listView.getSelectionModel().select(index);
          listView.scrollTo(index);
          listView.refresh();
        }
      }
    };
    bt1.setOnAction(ae1);
    bt2.setOnAction(ae1);
    bt3.setOnAction(e -> {
      int index = listView.getSelectionModel().getSelectedIndex();
      if (index >= 0) {
        olist.remove(index);
        if (index == p[0]) p[0] = -1;
        else if (index < p[0]) p[0]--;
      }
    });
    EventHandler<ActionEvent> ae2 = e -> {
      int index = listView.getSelectionModel().getSelectedIndex();
      if ((e.getTarget() == bt4 && index > 0 && olist.size() > 1) ||
          (e.getTarget() != bt4 && index >= 0 && index < olist.size() - 1)) {
        int newIndex = (e.getTarget() == bt4 ? index - 1 : index + 1);
        olist.set(index, olist.set(newIndex, olist.get(index)));
        if (newIndex == p[0]) p[0] = index;
        else if (index == p[0]) p[0] = newIndex;
        listView.getSelectionModel().select(newIndex);
        listView.scrollTo(newIndex);
      }
    };
    bt4.setOnAction(ae2);
    bt5.setOnAction(ae2);
    listView.setOnKeyPressed(ke -> {
      if (ke.getCode() == KeyCode.ENTER) bt2.fire();
      else if (ke.getCode() == KeyCode.ESCAPE) Event.fireEvent(dlg.getDialogPane(), ke);
    });
    listView.setPrefSize(240, 120);
    listView.getSelectionModel().selectFirst();
    return new VBox(3, new Label(tl2 + ":"), new HBox(10, listView, new VBox(6, bt1, bt2, bt3, new HBox(6, bt4, bt5))));
  }

  Canvas shp(int w, int h, String p) {
    Canvas canvas = new Canvas(w, h);
    GraphicsContext gc = canvas.getGraphicsContext2D();
    gc.setFill(Color.grayRgb(87));
    gc.appendSVGPath(p);
    gc.fill();
    return canvas;
  }

  ObservableList<String> getOblCharset() {
    if (oblCharset == null) {
      oblCharset = FXCollections.observableArrayList(Charset.defaultCharset().name(), StandardCharsets.UTF_8.name());
      oblCharset.addAll(Charset.availableCharsets().keySet());
    }
    return oblCharset;
  }

  void options() {
    Dialog<ButtonType> dialog = new Dialog<>();
    dialog.initOwner(stage);
    dialog.initStyle(StageStyle.UTILITY);
    dialog.setTitle("Options");
    dialog.setHeaderText(null);
    ComboBox<String> comboBox = new ComboBox<>(getOblCharset()); comboBox.setPrefWidth(150);
    comboBox.setValue(charset.name());
    ObservableList<String> oblCharset2 = FXCollections.observableArrayList("Charset + QP", "Specified", "Not specified");
    ComboBox<String> comboBox2 = new ComboBox<>(oblCharset2); comboBox2.setPrefWidth(150);
    comboBox2.setValue(oblCharset2.get(csSpecify));
    GridPane gridPane = new GridPane();
    gridPane.setHgap(10);
    gridPane.setVgap(6);
    Label l1, l2;
    gridPane.addRow(0, l1 = new Label("Text charset:"), comboBox); GridPane.setHalignment(l1, HPos.RIGHT);
    gridPane.addRow(1, l2 = new Label("Specify charset:"), comboBox2); GridPane.setHalignment(l2, HPos.RIGHT);
    dialog.getDialogPane().setContent(gridPane);
    dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
    Platform.runLater(() -> comboBox.requestFocus());
    Optional<ButtonType> result = dialog.showAndWait();
    if(result.isPresent() && result.get() == ButtonType.OK) {
      try { charset = Charset.forName(comboBox.getValue()); } catch (Exception e) { }
      csSpecify = oblCharset2.indexOf(comboBox2.getValue());
      lsb2.setText(charset.name() + (csSpecify == 0 ? "" : " / " + (csSpecify == 1 ? "sp" : "nsp")));
    }
  }

  Node search() {
    TextField tf = new TextField(); tf.setPrefWidth(200);
    Button bt1 = new Button(null, shp(8, 4, "M0 0 H8 L4 4 Z")),
           bt2 = new Button(null, shp(8, 4, "M0 4 H8 L4 0 Z"));
    bt1.setDisable(true); bt2.setDisable(true);
    tf.setOnKeyPressed(ke -> {
      if (ke.getCode() == KeyCode.ENTER) (ke.isShiftDown() ? bt2 : bt1).fire();
    });
    tf.textProperty().addListener((v, o, n) -> {
      boolean b = tf.getText().isBlank();
      bt1.setDisable(b); bt2.setDisable(b);
    });
    EventHandler<ActionEvent> ae = e -> {
      int index = tableView.getSelectionModel().getSelectedIndex();
      if (index >= 0) {
        int ind = index;
        VCard vc = null;
        do {
          index += (e.getTarget() == bt1 ? 1 : -1);
          if (index < 0) index = sortedList.size() - 1;
          else if (index >= sortedList.size()) index = 0;
          if (ind == index) break;
          vc = sortedList.get(index);
        } while (!vc.contains(tf.getText()));
        if (ind != index) {
          tableView.getSelectionModel().select(index);
          tableView.scrollTo(index);
        }
      }
    };
    bt1.setOnAction(ae);
    bt2.setOnAction(ae);
    HBox hb = new HBox(8, new Label("Search:"), new HBox(tf, bt1, bt2));
    hb.setAlignment(Pos.CENTER);
    hb.setPadding(new Insets(4, 15, 4, 0));
    return hb;
  }

}