2025/11/10

JavaFX Markdown Viewer

在 Java 處理 Markdown 文件,可使用 flexmark 套件,而 JavaFX,是使用 openjfx,因為 jfx 並不在 openjdk 裡面。

這邊因為使用舊版的 macos,所以使用很舊版本的openjfx

        <!-- markdown -->
        <dependency>
            <groupId>com.vladsch.flexmark</groupId>
            <artifactId>flexmark-all</artifactId>
            <version>0.64.8</version>
        </dependency>

        <!-- java FX -->
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>17.0.2</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-web</artifactId>
            <version>17.0.2</version>
        </dependency>

pom.xml下面的 plugin 這樣寫

    <build>
        <plugins>
            <!-- JavaFX Maven Plugin -->
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>MarkdownViewerApp</mainClass>
                </configuration>
            </plugin>

            <!-- Build fat JAR -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.7.1</version>
                <configuration>
                    <archive>
                        <manifest>
                            <mainClass>${main.class}</mainClass>
                        </manifest>
                    </archive>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>

            <!-- Java compiler -->
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.14.0</version>
                <configuration>
                    <source>${maven.compiler.source}</source>
                    <target>${maven.compiler.target}</target>
                </configuration>
            </plugin>
        </plugins>
    </build>

MarkdownTable

在 flexmark,要處理 table 時,需要加入 extension,以下是一個處理 Markdown Table 的 sample

import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.util.data.MutableDataSet;
import com.vladsch.flexmark.ext.tables.TablesExtension;

import java.util.Arrays;

public class MarkdownTableExample {
    public static void main(String[] args) {
        String markdown =
                "| Name  | Age |\n" +
                "|-------|-----|\n" +
                "| John  | 30  |\n" +
                "| Alice | 25  |";

        MutableDataSet options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));

        Parser parser = Parser.builder(options).build();
        HtmlRenderer renderer = HtmlRenderer.builder(options).build();

        String html = renderer.render(parser.parse(markdown));
        System.out.println(html);
    }
}

執行結果

<table>
<thead>
<tr><th>Name</th><th>Age</th></tr>
</thead>
<tbody>
<tr><td>John</td><td>30</td></tr>
<tr><td>Alice</td><td>25</td></tr>
</tbody>
</table>


Process finished with exit code 0

MarkdownViewer

html table 加上 css

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.TextArea;
import javafx.scene.layout.BorderPane;
import javafx.scene.web.WebView;
import javafx.stage.Stage;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.ext.tables.TablesExtension;
import com.vladsch.flexmark.util.data.MutableDataSet;

import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Scanner;

public class MarkdownViewerApp extends Application {

    private MutableDataSet options;
    private Parser parser;
    private HtmlRenderer renderer;

    @Override
    public void start(Stage primaryStage) {
        options = new MutableDataSet();
        options.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create()));
        parser = Parser.builder(options).build();
        renderer = HtmlRenderer.builder(options).build();

        // CSS
        String css = """
                body {
                    font-family: sans-serif;
                    margin: 20px;
                    line-height: 1.6;
                }
                table {
                    border-collapse: collapse;
                    width: 100%;
                }
                th, td {
                    border: 1px solid #ccc;
                    padding: 6px 10px;
                }
                th {
                    background-color: #f0f0f0;
                }
                """;

        TextArea markdownInput = new TextArea();
        WebView htmlView = new WebView();

        // Default text
//        markdownInput.setText("# Hello Markdown\n\nThis is **bold** and this is _italic_.");
        try (InputStream in = getClass().getResourceAsStream("/test.md")) {
            if (in != null) {
                Scanner scanner = new Scanner(in, StandardCharsets.UTF_8).useDelimiter("\\A");
                String htmlContent = scanner.hasNext() ? scanner.next() : "";

                markdownInput.setText(htmlContent);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

        // Convert and display Markdown as HTML
        markdownInput.textProperty().addListener((obs, oldVal, newVal) -> {
            Node document = parser.parse(newVal);
            String htmlBody = renderer.render(document);
            String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
            htmlView.getEngine().loadContent(html);
        });

        // Initial render
        Node document = parser.parse(markdownInput.getText());
        String htmlBody = renderer.render(document);
        String html = """
                <html>
                <head>
                <style>%s</style>
                </head>
                <body>
                    %s
                </body>
                </html>
            """.formatted(css, htmlBody);
        htmlView.getEngine().loadContent(html);

        BorderPane root = new BorderPane();
        root.setLeft(markdownInput);
        root.setCenter(htmlView);

        Scene scene = new Scene(root, 800, 600);
        primaryStage.setTitle("JavaFX Markdown Viewer");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

可直接用 mvn 啟動

mvn javafx:run

獨立執行需下載 openjfx sdk,可到 JavaFX - Gluon 下載,openjfx-17.0.2_osx-x64_bin-sdk.zip

執行 mvn package 可產生 jar,然後就能這樣啟動

java --module-path ~/Downloads/javafx-sdk-17.0.2/lib/ --add-modules javafx.controls,javafx.web -jar target/markdownviewer-1.0-SNAPSHOT-jar-with-dependencies.jar MarkdownViewerApp

這是執行結果

2025/11/03

JSch

Jsch 是 Java 實作的 ssh, sftp library,這個函式庫原本是由 com.jcraft:jsch 實作,但在 2018 就停止更新,故現在要改使用 com.github.mwiede:jsch

只需要在 maven 加入 library 即可,最低可使用 Java 8

<dependency>
  <groupId>com.github.mwiede</groupId>
  <artifactId>jsch</artifactId>
  <version>2.27.0</version>
</dependency>

exec

jsch 最基本是使用 exec channel,一次執行一個指令,沒有 context,單獨執行的指令。

以下程式另外用 Scanner 包裝一個簡單的互動介面

import com.jcraft.jsch.ChannelExec;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;

import org.apache.commons.io.IOUtils;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class JSchExecTest {
    private Session session;

    public void connect(String host, int port, String user, String pwd) throws JSchException {
        disconnect();
        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(pwd);
        // yes / no / ask
        session.setConfig("StrictHostKeyChecking", "no");
        session.connect();
        this.session = session;
    }

    public void disconnect() throws JSchException {
        if( session!=null ) {
            session.disconnect();
            session = null;
        }
    }

    public void execCmd(String command) throws JSchException, IOException {
        if( session==null ) {
            return;
        }
//        ChannelExec    單指令,單獨執行(適合非交互式)
//        ChannelShell    多指令、有狀態、有上下文需求(如 cd)
        ChannelExec channelExec = (ChannelExec) session.openChannel("exec");
        channelExec.setCommand(command);

        // set error output stream
        // channelExec.setErrStream(System.err);
        ByteArrayOutputStream errorOutputStream = new ByteArrayOutputStream();
        channelExec.setErrStream(errorOutputStream);

        // get command output
        InputStream in = channelExec.getInputStream();

        // execute command
        channelExec.connect();

        //// get output content
//        BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
//        String line;
//        while ((line = reader.readLine()) != null) {
//            System.out.println(line);
//        }
//        reader.close();

        String output = IOUtils.toString(in, StandardCharsets.UTF_8);
        if (!output.isEmpty()) {
            System.out.println("Exec Output: " + command + "\r\n" + output);
        }

        // error
        String errorMsg = errorOutputStream.toString();
        if (!errorMsg.isEmpty()) {
            System.err.println("Error Output: " + errorMsg);
        }

        channelExec.disconnect();
    }

    public void printhelp() {
        System.out.println("connect host port username password");
        System.out.println("exec command");
        System.out.println("disconnect");
        System.out.println("");
    }

    public static void main(String[] args) {
        JSchExecTest jSchExecTest = new JSchExecTest();
        try {
//            session = JschTest1.connect("192.168.1.89", 22, "user", "pass");
//            JschTest1.execCmd(session, "ls -al");
//            JschTest1.execCmd(session, "ls -al ggg");
//            JschTest1.disconnect(session);

            Scanner scanner = new Scanner(System.in);

            System.out.println("jsch CLI: Enter a command (type 'exit' to quit)");
            while (true) {
                System.out.print("> ");
                String input = scanner.nextLine().trim();
                if (input.isEmpty()) continue;

                // 分割整行輸入,第一個為指令,其餘為參數
                String[] parts = input.split("\\s+", 2); // 最多分兩段:指令 和 其餘參數
                String command = parts[0];
                String arguments = parts.length > 1 ? parts[1] : "";

                if ("exit".equalsIgnoreCase(command)) {
                    System.out.println("Goodbye!");
                    break;
                }

                switch (command.toLowerCase()) {
                    case "h":
                        jSchExecTest.printhelp();
                        break;
                    case "help":
                        jSchExecTest.printhelp();
                        break;
                    case "?":
                        jSchExecTest.printhelp();
                        break;
                    case "connect":
                        String[] argparts = arguments.split("\\s+");
                        if( argparts.length == 4 ) {
                            try {
                                jSchExecTest.connect(argparts[0], Integer.parseInt(argparts[1]), argparts[2], argparts[3]);
                            } catch (JSchException je) {
                            }
                        } else {
                            System.out.println("connect host port username password");
                        }
                        break;
                    case "disconnect":
                        System.out.println("Current time: " + java.time.LocalTime.now());
                        jSchExecTest.disconnect();
                        break;
                    case "exec":
                        jSchExecTest.execCmd(arguments);
                        break;
                    default:
                        System.out.println("Unknown command: " + input);
                }
            }
            scanner.close();
        } catch (JSchException e) {
            e.printStackTrace();
        } catch (IOException ioe) {
            ioe.printStackTrace();
        }
    }
}

測試

jsch CLI: Enter a command (type 'exit' to quit)
> connect 192.168.1.89 22 user pass
> ls -al
Unknown command: ls -al
> exec ls -al
Exec Output: ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
.....
> disconnect
Current time: 17:50:59.521429
> exit
Goodbye!

shell

像是 cd 指令,需要記錄目錄,就需要改用 shell channell

import com.jcraft.jsch.*;

import java.io.*;

public class JSchInteractiveShell {

    public static void main(String[] args) throws Exception {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "s2papago";
        String sudoPassword = "pass";

        JSch jsch = new JSch();
        Session session = jsch.getSession(user, host, port);
        session.setPassword(password);

        // 不驗證 host key(開發用,正式環境請換成嚴格檢查)
//        session.setConfig("StrictHostKeyChecking", "no");
        // 指定 known_hosts 檔案位置 (通常是 ~/.ssh/known_hosts)
        String knownHostsPath = System.getProperty("user.home") + "/.ssh/known_hosts";
        jsch.setKnownHosts(knownHostsPath);
        session.setConfig("StrictHostKeyChecking", "yes");

        session.connect();

        ChannelShell channel = (ChannelShell) session.openChannel("shell");

        InputStream in = channel.getInputStream();
        OutputStream out = channel.getOutputStream();

        PrintWriter writer = new PrintWriter(out, true);
        channel.connect();

        // thread 持續讀取遠端輸出
        Thread readerThread = new Thread(() -> {
            try (BufferedReader reader = new BufferedReader(new InputStreamReader(in))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    System.out.println(line);
//                    // 判斷是否有 sudo 密碼提示 (常見字串,可視環境調整)
//                    if (line.toLowerCase().contains("[sudo] password") ||
//                        line.toLowerCase().contains("password for " + user.toLowerCase()) || line.toLowerCase().contains("密碼:")) {
//                        System.out.println("[INFO] Detected sudo password prompt, sending password...");
//                        writer.println(sudoPassword);  // 自動送 sudo 密碼
//                    }
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        readerThread.start();
        Thread.sleep(1000);
        // main thread: 從 System.in 讀使用者輸入,傳給遠端 shell
        try (BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in)) ) {

            String command;
            System.out.print("$ ");  // prompt
            while ((command = consoleReader.readLine()) != null) {
                writer.println(command);
                if ("exit".equalsIgnoreCase(command.trim())) {
                    break;
                }
                System.out.print("$ ");
            }
        }

        readerThread.join();
        channel.disconnect();
        session.disconnect();
    }
}

測試

$ ls -al
[larzio@lzstg2 ~]$ ls -al
總計 24
drwx------. 5 larzio larzio 159  6月 11 16:31 .
drwxr-xr-x. 3 root   root    20  9月 11  2024 ..
.............
cd download
$ [larzio@lzstg2 ~]$ cd download
l s-al
$ [larzio@lzstg2 download]$ l s-al
-bash: l:命令找不到
ls -al
$ [larzio@lzstg2 download]$ ls -al
總計 16
drwxr-xr-x  2 root   root     70  6月 11 16:29 .
drwx------. 5 larzio larzio  159  6月 11 16:31 ..
.............
exit
[larzio@lzstg2 download]$ exit
登出

sftp

上傳或下載檔案,如果要能支援目錄,要使用 sftp channel

import com.jcraft.jsch.*;

import java.io.*;
import java.util.Vector;

public class JSchSftpUtil {

    private Session session;
    private ChannelSftp sftp;

    public JSchSftpUtil(Session session) throws JSchException {
        this.session = session;
        Channel channel = session.openChannel("sftp");
        channel.connect();
        sftp = (ChannelSftp) channel;
    }

    public void disconnect() {
        if (sftp != null && sftp.isConnected()) sftp.disconnect();
        if (session != null && session.isConnected()) session.disconnect();
    }

    public void upload(String localPath, String remotePath, int permission) throws Exception {
        File localFile = new File(localPath);
        if (!localFile.exists()) throw new FileNotFoundException(localPath);

        if (localFile.isFile()) {
            uploadFileWithResume(localFile, remotePath, permission);
        } else {
            uploadDirectory(localFile, remotePath, permission);
        }
    }

    public void download(String remotePath, String localPath) throws Exception {
        SftpATTRS attrs = null;
        try {
            attrs = sftp.stat(remotePath);
        } catch (Exception e) {
            throw new FileNotFoundException("Remote path not found: " + remotePath);
        }

        File localFile = new File(localPath);
        if (attrs.isDir()) {
            downloadDirectory(remotePath, localFile);
        } else {
            downloadFileWithResume(remotePath, localFile);
        }
    }

    // 進度監控
    private static class MyProgressMonitor implements SftpProgressMonitor {
        private long max = 0;
        private long count = 0;
        private long percent = -1;

        public MyProgressMonitor(long max) {
            this.max = max;
        }

        @Override
        public void init(int op, String src, String dest, long max) {
            System.out.println("Start transfer: " + src + " -> " + dest);
        }

        @Override
        public boolean count(long count) {
            this.count += count;
            long newPercent = this.count * 100 / max;
            if (newPercent != percent) {
                percent = newPercent;
                System.out.print("\rProgress: " + percent + "%");
            }
            return true;
        }

        @Override
        public void end() {
            System.out.println("\nTransfer complete");
        }
    }

    // 斷點續傳上傳單檔
    private void uploadFileWithResume(File localFile, String remoteFilePath, int permission) throws Exception {
        long localFileSize = localFile.length();
        long remoteFileSize = 0;

        try {
            SftpATTRS attrs = sftp.stat(remoteFilePath);
            remoteFileSize = attrs.getSize();
        } catch (SftpException e) {
            // 檔案不存在
            remoteFileSize = 0;
        }

        if (remoteFileSize == localFileSize) {
            System.out.println("Remote file already complete, skipping upload: " + remoteFilePath);
            return;
        } else if (remoteFileSize > localFileSize) {
            System.out.println("Remote file is larger than local file, overwriting");
            remoteFileSize = 0;
        }

        try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) {
            raf.seek(remoteFileSize);

            InputStream fis = new InputStream() {
                @Override
                public int read() throws IOException {
                    return raf.read();
                }

                @Override
                public int read(byte[] b, int off, int len) throws IOException {
                    return raf.read(b, off, len);
                }
            };

            sftp.put(fis, remoteFilePath, new MyProgressMonitor(localFileSize), ChannelSftp.APPEND);
            fis.close();
        }

        // 設定遠端檔案權限
        sftp.chmod(permission, remoteFilePath);
    }

    // 遞迴上傳目錄
    private void uploadDirectory(File localDir, String remoteDir, int permission) throws Exception {
        try {
            sftp.cd(remoteDir);
        } catch (SftpException e) {
            sftp.mkdir(remoteDir);
            sftp.cd(remoteDir);
        }

        for (File file : localDir.listFiles()) {
            if (file.isFile()) {
                uploadFileWithResume(file, remoteDir + "/" + file.getName(), permission);
            } else if (file.isDirectory()) {
                uploadDirectory(file, remoteDir + "/" + file.getName(), permission);
            }
        }

        sftp.cd("..");
    }

    // 斷點續傳下載單檔
    private void downloadFileWithResume(String remoteFilePath, File localFile) throws Exception {
        long remoteFileSize = sftp.stat(remoteFilePath).getSize();
        long localFileSize = 0;

        if (localFile.exists()) {
            localFileSize = localFile.length();
            if (localFileSize > remoteFileSize) {
                System.out.println("Local file larger than remote, overwrite");
                localFileSize = 0;
            } else if (localFileSize == remoteFileSize) {
                System.out.println("Local file already complete, skipping download: " + localFile.getAbsolutePath());
                return;
            }
        }

        OutputStream os;
        if (localFileSize > 0) {
            os = new FileOutputStream(localFile, true);
        } else {
            os = new FileOutputStream(localFile);
        }

        try (OutputStream outputStream = os) {
            sftp.get(remoteFilePath, outputStream, new MyProgressMonitor(remoteFileSize), ChannelSftp.RESUME, localFileSize);
        }
    }

    // 遞迴下載目錄
    private void downloadDirectory(String remoteDir, File localDir) throws Exception {
        if (!localDir.exists()) {
            localDir.mkdirs();
        }

        Vector<ChannelSftp.LsEntry> list = sftp.ls(remoteDir);

        for (ChannelSftp.LsEntry entry : list) {
            String filename = entry.getFilename();
            if (".".equals(filename) || "..".equals(filename)) continue;

            String remoteFilePath = remoteDir + "/" + filename;
            File localFilePath = new File(localDir, filename);

            if (entry.getAttrs().isDir()) {
                downloadDirectory(remoteFilePath, localFilePath);
            } else {
                downloadFileWithResume(remoteFilePath, localFilePath);
            }
        }
    }

    public static void main(String[] args) {
        String user = "user";
        String host = "192.168.1.89";
        int port = 22;
        String password = "pass";

        String localUploadPath = "/Users/charley/Downloads/temp";
        String remoteUploadPath = "/home/larzio/temp";

        String remoteDownloadPath = "/home/larzio/download";
        String localDownloadPath = "/Users/charley/Downloads/testdownload";

        int permission = 0644;

        JSch jsch = new JSch();
        Session session = null;
        JSchSftpUtil sftpUtil = null;

        try {
            session = jsch.getSession(user, host, port);
            session.setPassword(password);
            session.setConfig("StrictHostKeyChecking", "no");
            session.connect();

            sftpUtil = new JSchSftpUtil(session);

            // 上傳
            sftpUtil.upload(localUploadPath, remoteUploadPath, permission);
            System.out.println("Upload done.");

            // 下載
            sftpUtil.download(remoteDownloadPath, localDownloadPath);
            System.out.println("Download done.");

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (sftpUtil != null) sftpUtil.disconnect();
        }
    }
}

References

JSch 的使用(SSH、SFTP) · Homurax's Blog

GitHub - mwiede/jsch: fork of the popular jsch library

2025/10/27

Varnish

varnish 是一種反向代理伺服器軟體,以記憶體方式存取 cache。透過 VCL (Varnish Configuration Language) 讓使用者設定 varnish。

安裝

在 Rocky Linux 8 安裝

dnf -y install varnish

設定

修改原本的設定檔

mv /etc/varnish/default.vcl /etc/varnish/default.vcl.bak
vi /etc/varnish/default.vcl

此設定檔只會 cache png 圖片檔案 7 天

backend default {
    .host = "127.0.0.1";  # Your app server
    .port = "8081";       # Your app server port
}

sub vcl_recv {
    # 僅對 GET 和 HEAD 快取
    if (req.method != "GET" && req.method != "HEAD") {
        return (pass);
    }

    # 僅快取 .png 圖檔,其餘請求不快取
    # 比對 URL 結尾為 .png
    if (req.url ~ "\.png$") {
        # cache
        return (hash);
    } else {
        # pass
        return (pass);
    }
}

sub vcl_backend_response {
    if (beresp.status == 200) {
        # Cache for 7 day
        set beresp.ttl = 7d;
    } else {
        set beresp.ttl = 0s;
    }
}

sub vcl_deliver {
    if (obj.hits > 0) {
        set resp.http.X-Cache = "HIT";
    } else {
        set resp.http.X-Cache = "MISS";
    }
}

啟動

systemctl start varnish
systemctl enable varnish

修改 disk cache

Varnish 無法永久將 cache 儲存到 disk (decprecated),但可透過 memory mapping,將記憶體的資料存放到 file

sudo systemctl edit varnish

修改 service

[Service]
ExecStart=
ExecStart=/usr/sbin/varnishd \
  -a :6081 \
  -f /etc/varnish/default.vcl \
  -s file,/var/lib/varnish/varnish_storage.bin,2G

確認 file folder 存在

sudo mkdir -p /var/lib/varnish
sudo chown varnish: /var/lib/varnish

restart Varnish

# restart varnish
sudo systemctl daemon-reexec
sudo systemctl restart varnish