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
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
- JavaFX 15.0.1
- houbb/opencc4j
- Apache commons-compress
- Apache commons-cli
- Google GSON
- TocasUI (CSS styles) (MIT)
- Google Font Material Icons (Apache License 2.0)
Repo
Released under MIT License!
Thanks for listening
EPUB 簡轉繁
By Tony Yang
EPUB 簡轉繁
NCU CSIE Final Project: EPUB 簡轉繁
- 305