EPUB 簡轉繁

Tony Yang

Features

  • Cross-platform support
  • Convert EPUB with GUI / Command
  • Modern, easy-to-use, responsive UI
  • Recursively convert EPUBs in directory
  • Support Drag and Drop to import files / directories
  • Custom Configurations
    • Output Directory
    • Output Filename
    • Overwrite existing file?
    • Minimum alert level

Screenshot

Structure

AppStarter

GUILauncher

CommandLineApp

no arg

with args

EPUBConvertor

AppState

AppConfig

AppConfig

MVC

MVC

  • Model
    • Handle data actions
  • View
    • Present data
  • Controller
    • Handle user events, decide what to do

Image Source: wiki

Model

  • AppState
    • central management, same state across app
    • file list, convert mode
  • AppConfig
    • OutputMode, path
  • EPUBFile
    • EPUBFile convert status, path, output path

Data Binding

Property, Observable

  • JavaFX builtin
  • Use .get() and .set()
  • If value changes, we can use binding or listener.
  • We can bind one property to another

Simple Binding

pathColumn.minWidthProperty().bind(fileList.widthProperty().multiply(0.66));

modeLabel.textProperty().bind(state.getMode().asString());

Conditional Binding

Bindings.when()

convertButton.textProperty().bind(
    Bindings.when(state.getMode().isNotEqualTo(AppMode.CONVERTING))
            .then("Convert")
            .otherwise("Cancel")
);

Complex Binding

with listeners

state.getMode().addListener((observable, oldValue, newValue) -> {
    bindProgressLabel(newValue);
    bindProgressBar(newValue);

    Map<String, Boolean> convertButtonClassMap;
    if (newValue != AppMode.CONVERTING) {
        convertButtonClassMap = Map.of("primary", true, "negative", false);
    } else {
        convertButtonClassMap = Map.of("primary", false, "negative", true);
    }
    toggleClassMap(convertButton, convertButtonClassMap);

    Map<String, Boolean> progressbarClassMap;
    switch (newValue) {
        case DONE -> progressbarClassMap = Map.of("positive", true, "negative", false);
        case INTERRUPTED -> progressbarClassMap = Map.of("positive", false, "negative", true);
        default -> progressbarClassMap = Map.of("positive", false, "negative", false);
    }
    toggleClassMap(progressbar, progressbarClassMap);
 });

Table

TableColumn.setCellValueFactory()

fileList.setItems(state.getFiles());

statusColumn.setCellValueFactory(data -> data.getValue().getStatus().asString());
statusColumn.setCellFactory(data -> new TableCell<>() {
    @Override
    public void updateItem(String item, boolean empty) {
        super.updateItem(item, empty);
        setText(item);
        if (empty) return;

        toggleClassMap(this, Map.of("positive", item.equals("SUCCESS"), "negative", item.equals("FAILED")));
    }
});
nameColumn.setCellValueFactory(new PropertyValueFactory<>("filename"));
pathColumn.setCellValueFactory(new PropertyValueFactory<>("path"));

Bindings.size() not work?

Garbage Collection

binding = Bindings.createStringBinding(
        () -> MessageFormat.format("0 / {0}", state.getFiles().getSize()),
        Bindings.size(state.getFiles())
);

Reference: stackoverflow

binding = Bindings.createStringBinding(
        () -> MessageFormat.format("0 / {0}", state.getFiles().getSize()),
        state.getFiles().sizeProperty()
);

vs

Concurrent

Task

javafx.concurrent.Task

private class ConversionTask extends Task<Void> {
    int conversionCnt = 0;
    List<EPUBFile> files;

    public ConversionTask(List<EPUBFile> files) {
        this.files = files;
    }

    @Override
    public Void call() throws InterruptedException {
        // put initial value so the progress won't be -1/-1
        this.updateProgress(0, files.size());
        for (EPUBFile file: files) {
            convertor.convert(file, state.getConfig());
            conversionCnt++;
            this.updateProgress(conversionCnt, files.size());
        }
        return;
    }
}

Usage

// start task
conversionTask.setOnSucceeded(ev -> {
    state.setMode(AppMode.DONE);
    showConversionResult(conversionTask.getValue().getLeftValue());
});

conversionThread = new Thread(conversionTask);
conversionThread.setDaemon(true);
conversionThread.start();

// interrupt task
conversionTask.cancel();

// bind to progressbar
progressbar.progressProperty().bind(conversionTask.progressProperty());

Interruption of Thread

Thread.currentThread().isInterrupted()

while (entries.hasMoreElements()) {
    // stop conversion if interrupt signal occurs.
    if (Thread.currentThread().isInterrupted()) throw new InterruptedException();

    entry = entries.nextElement();

    // ...
}

Responsive View

GridPane, HGrow, VGrow

Wrapping GridPanes

FXML

vgrow="ALWAYS"

<GridPane fx:id="root" prefHeight="540.0" prefWidth="720.0" vgap="10.0" xmlns="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="tech.stoneapp.epub.gui.controller.AppController">
    <columnConstraints>
        <ColumnConstraints hgrow="ALWAYS" minWidth="10.0" prefWidth="100.0" />
    </columnConstraints>
    <rowConstraints>
        <RowConstraints minHeight="10.0" prefHeight="30.0" />
        <RowConstraints minHeight="10.0" prefHeight="30.0" />
        <RowConstraints minHeight="10.0" prefHeight="30.0" vgrow="ALWAYS" />
        <RowConstraints minHeight="130.0" prefHeight="130.0" vgrow="SOMETIMES" />
    </rowConstraints>
    <padding>
        <Insets bottom="15.0" left="15.0" right="15.0" top="15.0" />
    </padding>
  	<children>
    	<!-- some node -->
    </children>
</GridPane>

Effect

Drag and Drop

DragEvent

Example

DragOver, DragDropped, TransferModes()

fileList.setOnDragOver(ev -> {
    if (ev.getDragboard().hasFiles()) {
        ev.acceptTransferModes(TransferMode.COPY_OR_MOVE);
    }
    ev.consume();
});

fileList.setOnDragDropped(ev -> {
    if (ev.getDragboard().hasFiles()) {
        List<File> files = ev.getDragboard().getFiles();

        importEPUB(files);
    }
    ev.consume();
});

DesktopAPI

cross platform support is complex

File Manager

  • KDE:     kde-open
  • GNOME:   gnome-open
  • Any X-server system: xdg-open
  • MAC:     open
  • Windows: explorer

Reference: stackoverflow

DesktopAPI

Reference: stackoverflow

public class DesktopAPI {
    public DesktopAPI() {}

    public static boolean showInFolder(File file) {
        if (!file.exists()) return false;
        String path = file.getAbsolutePath();

        OS osPlatform = getOS();
        String[] command = null;
        switch (osPlatform) {
            // magic don't touch
            case windows:
                // on windows, pass String[] failed.
                command = new String[] {String.format("explorer.exe /select,\"%s\"", path)};
                break;
            case macos:
                // on Mac, pass String failed. ????
                command = new String[] {"open", "-R", path};
                break;
            case linux:
                // use Desktop.getDesktop() to handle files on linux, for there are too many cases on linux.
                Desktop.getDesktop().browse(file.toURI());
            default:
                return false;
        }
        // only command for Windows and MacOS
        return runCommand(command);
    }
}

Other Technical Details

I hate cmd

Why '*' works?

  • Windows cmd does not expand * in arguments by defualt.
  • In fact, it is JVM that expands *, and you would only get expanded result in String[] args.

Why '*' works?

  • Windows cmd does not expand * in arguments by defualt.
  • In fact, it is JVM that expands *, and you would only get expanded result in String[] args.
  •  Java "write once, run everywhere" philosophy

Related Problems: stackoverflow

stackoverflow

Singleton

public class EPUBConvertor {
    private static EPUBConvertor instance;

    private EPUBConvertor() {}

    public static EPUBConvertor getInstance() {
        if (instance == null) instance = new EPUBConvertor();
        return instance;
    }
}

Signature Security

subclass with private constructor

public class EPUBConvertor {
    // signature security
    // https://stackoverflow.com/a/18634125/9039813
    public static final class EPUBAccessor { private EPUBAccessor() {} }
    private static final EPUBAccessor accessor = new EPUBAccessor();
    
    private EPUBConvertor() {}
}

Reference: stackoverflow

Signature Security

subclass with private constructor

public class EPUBFile {
    private ObjectProperty<ConvertStatus> status = new SimpleObjectProperty<>(ConvertStatus.PENDING);

    public void updateStatus(ConvertStatus status, EPUBConvertor.EPUBAccessor accessor) {
        // slap you with NullPointerException
        Objects.requireNonNull(accessor);
        this.status.setValue(status);
    }   
}

Reference: stackoverflow

Demo

GitHub Actions

Workflow

Define jobs in .github/workflows/action.yml

name: Build App to JAR

on:
  push:
    branch: master
    tags:
      - 'v*.*.*'

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Set up JDK
      uses: actions/setup-java@v2
      with:
        java-version: '15'
        distribution: 'adopt'
    - name: Grant execute permission for gradlew
      run: chmod +x gradlew
    - name: Build with Gradle
      run: ./gradlew jar
    - name: Set output
      id: vars
      run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
    - name: Create Release
      uses: "marvinpinto/action-automatic-releases@latest"
      with:
        repo_token: "${{ secrets.GITHUB_TOKEN }}"
        title: "Release ${{ steps.vars.outputs.tag }}"
        files: |
          build/libs/*.jar

Auto build & release

Further Improvements

  • DragOver Indicator (Animation)
  • Handle errors elegantly (No more e.printStackTrace())
  • More custom options
  • Styling Alert Dialogs
  • ...more (PR Welcome!)

Used Libraries

Repo

Released under MIT License!

Thanks for listening

EPUB 簡轉繁

By Tony Yang

EPUB 簡轉繁

NCU CSIE Final Project: EPUB 簡轉繁

  • 288