即実行または指定時間/間隔でコピーや同期が出来る「rsyncGUI」

ソフト名通りのソフトですが、画面内の文字が少し小さかったのと、スケジュール機能に柔軟性がなかったので修正しました。指定した曜日の決まった時間、もしくは指定したインターバルごとにコピーもしくは同期が行えます。
使い方の説明はこちらで。

LXDコンテナにインストール

#!/bin/bash
set -e

INSTALL_DIR="/opt/rsyncgui"
PORT=3326
SERVICE_NAME="rsyncgui"

echo "=== rsyncGUI Installer ==="

# Detect package manager
if command -v apt-get &>/dev/null; then
    PKG="apt"
elif command -v yum &>/dev/null; then
    PKG="yum"
elif command -v dnf &>/dev/null; then
    PKG="dnf"
elif command -v apk &>/dev/null; then
    PKG="apk"
else
    echo "Unsupported package manager. Install python3, rsync, sshpass manually."
    exit 1
fi

echo "[1/6] Installing dependencies..."
case $PKG in
    apt)
        apt-get update -qq
        apt-get install -y -qq python3 rsync sshpass openssh-client
        ;;
    yum)
        yum install -y -q python3 rsync sshpass openssh-clients
        ;;
    dnf)
        dnf install -y -q python3 rsync sshpass openssh-clients
        ;;
    apk)
        apk add --no-cache python3 rsync sshpass openssh-client
        ;;
esac

echo "[2/6] Creating install directory..."
mkdir -p "$INSTALL_DIR/public"

echo "[3/6] Writing server..."
cat > "$INSTALL_DIR/server.py" << 'PYEOF'
#!/usr/bin/env python3
import http.server
import json
import os
import subprocess
import threading
import time
import signal
import sys
import tempfile
from urllib.parse import urlparse, parse_qs

PORT = 3326
PUBLIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public")
CONFIG_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
CRON_TAG = "# rsyncgui-managed"
INTERVAL_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "intervals.json")

MIME = {
    ".html": "text/html",
    ".css": "text/css",
    ".js": "application/javascript",
    ".json": "application/json",
    ".svg": "image/svg+xml",
    ".png": "image/png",
}

processes = {}
interval_processes = {}


def load_config():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r") as f:
            return json.load(f)
    return {"pairs": [], "schedules": []}


def save_config(cfg):
    with open(CONFIG_FILE, "w") as f:
        json.dump(cfg, f, indent=2, ensure_ascii=False)


def build_rsync_cmd(job):
    """
    修正点:
    - mode による -avz のベタ書きを廃止。
      mode=copy -> -r のみ追加(再帰コピー)
      mode=sync -> オプションチェックに完全委任(二重適用防止)
    - --partial のショートオプション (-P) を削除。
      -P は rsync では --partial --progress の複合であり、
      --partial 単独のショートオプションは存在しない。
    - --backup-dir の値があれば --backup と合わせて渡す。
    """
    args = []
    mode = job.get("mode", "sync")

    # copy モードのみ -r を先頭に付与。sync はオプションに任せる。
    if mode == "copy":
        args.append("-r")

    options = job.get("options", {})
    for key, val in options.items():
        if key == "backup-dir":
            # backup-dir は値付きオプション。空文字なら無視。
            if val and str(val).strip():
                args.append(f"--backup-dir={val}")
        elif val is True:
            args.append(f"--{key}")
        elif val is not False and val != "" and val is not None:
            args.append(f"--{key}={val}")

    if job.get("dryRun") or job.get("dry_run"):
        args.append("--dry-run")

    ssh_cfg = job.get("ssh")
    if ssh_cfg:
        ssh_parts = ["ssh"]
        if ssh_cfg.get("port") and ssh_cfg["port"] != "22":
            ssh_parts.append(f"-p {ssh_cfg['port']}")
        if ssh_cfg.get("key"):
            ssh_parts.append(f"-i {ssh_cfg['key']}")
        ssh_parts.append("-o StrictHostKeyChecking=no")
        args.append("-e")
        args.append(" ".join(ssh_parts))

    exclude = job.get("exclude", "")
    for line in exclude.split("\n"):
        line = line.strip()
        if line:
            args.append(f"--exclude={line}")

    source = job.get("source", "")
    target = job.get("target", "")

    if ssh_cfg and ssh_cfg.get("host"):
        user_prefix = f"{ssh_cfg['user']}@" if ssh_cfg.get("user") else ""
        if ":" not in target:
            target = f"{user_prefix}{ssh_cfg['host']}:{target}"

    args.append(source)
    args.append(target)

    use_sshpass = ssh_cfg and ssh_cfg.get("password") and ssh_cfg.get("host")
    password = ssh_cfg.get("password", "") if use_sshpass else ""
    return args, use_sshpass, password


def get_crontab():
    try:
        result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
        if result.returncode == 0:
            return result.stdout
    except Exception:
        pass
    return ""


def set_crontab(content):
    proc = subprocess.run(["crontab", "-"], input=content, text=True, capture_output=True)
    return proc.returncode == 0


def schedule_to_cron_time(schedule):
    time_str = schedule.get("time", "02:00")
    hour, minute = time_str.split(":")
    days = schedule.get("days", [])
    if not days:
        return None
    day_str = ",".join(str(d) for d in days)
    return f"{minute} {hour} * * {day_str}"


def pair_to_cron_cmd(pair):
    job = {
        "source": pair.get("source", ""),
        "target": pair.get("target", ""),
        "mode": pair.get("mode", "sync"),
        "options": pair.get("options", {}),
        "ssh": pair.get("ssh") if pair.get("sshEnabled") else None,
        "exclude": pair.get("exclude", ""),
        "dryRun": pair.get("dryRun", False),
    }
    args, use_sshpass, password = build_rsync_cmd(job)
    if use_sshpass:
        return f"sshpass -f /tmp/rsyncgui_cron_pw_{pair.get('id', '?')} rsync " + " ".join(args)
    return "rsync " + " ".join(args)


def sync_crontab(pairs):
    existing = get_crontab()
    lines = [l for l in existing.splitlines() if CRON_TAG not in l]

    for pair in pairs:
        sched = pair.get("schedule", {})
        if not sched.get("enabled"):
            continue
        sched_mode = sched.get("mode", "time")
        if sched_mode == "interval":
            continue
        time_str = sched.get("time", "02:00")
        days = sched.get("days", [])
        if not days:
            continue
        hour, minute = time_str.split(":")
        day_str = ",".join(str(d) for d in days)
        cron_time = f"{minute} {hour} * * {day_str}"
        cmd = pair_to_cron_cmd(pair)
        cron_line = f"{cron_time} {cmd} {CRON_TAG} id={pair.get('id', '?')}"
        lines.append(cron_line)

    new_crontab = "\n".join(lines) + "\n" if lines else ""
    return set_crontab(new_crontab)


def load_intervals():
    if os.path.exists(INTERVAL_FILE):
        with open(INTERVAL_FILE, "r") as f:
            return json.load(f)
    return []


def save_intervals(intervals):
    with open(INTERVAL_FILE, "w") as f:
        json.dump(intervals, f, indent=2, ensure_ascii=False)


def start_interval_scheduler(pairs):
    for pair_id in list(interval_processes.keys()):
        if not any(p.get("id") == pair_id for p in pairs):
            interval_processes[pair_id]["stop"] = True

    for pair in pairs:
        sched = pair.get("schedule", {})
        if not sched.get("enabled") or sched.get("mode") != "interval":
            continue
        pair_id = pair.get("id")
        if pair_id in interval_processes:
            existing = interval_processes[pair_id]
            if existing.get("interval") == sched.get("interval") and existing.get("unit") == sched.get("intervalUnit"):
                continue
            existing["stop"] = True
        interval_seconds = sched.get("interval", 60) * {"s": 1, "m": 60, "h": 3600}.get(sched.get("intervalUnit", "s"), 1)
        stop_flag = {"stop": False}
        interval_processes[pair_id] = {"interval": sched.get("interval"), "unit": sched.get("intervalUnit"), "stop": False, "thread": None}

        def run_interval(pid, secs, pair_data, sf):
            while not sf["stop"]:
                time.sleep(1)
                if sf["stop"]:
                    break
                job = {
                    "source": pair_data.get("source", ""),
                    "target": pair_data.get("target", ""),
                    "mode": pair_data.get("mode", "sync"),
                    "options": pair_data.get("options", {}),
                    "ssh": pair_data.get("ssh") if pair_data.get("sshEnabled") else None,
                    "exclude": pair_data.get("exclude", ""),
                    "dryRun": pair_data.get("dryRun", False),
                }
                args, use_sshpass, password = build_rsync_cmd(job)
                job_id = f"int_{pid}_{int(time.time() * 1000)}"
                pw_file = None
                try:
                    if use_sshpass:
                        pw_file = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix='rsyncgui_intpw_')
                        pw_file.write(password)
                        pw_file.close()
                        os.chmod(pw_file.name, 0o600)
                        cmd_list = ["sshpass", "-f", pw_file.name, "rsync"] + args
                    else:
                        cmd_list = ["rsync"] + args
                    print(f"[rsyncgui] interval run: pid={pid} cmd={' '.join(cmd_list)}", flush=True)
                    proc = subprocess.Popen(cmd_list, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
                    processes[job_id] = {"proc": proc, "log": [], "done": False, "exitCode": None}
                    for line in proc.stdout:
                        processes[job_id]["log"].append({"type": "stdout", "text": line})
                    for line in proc.stderr:
                        processes[job_id]["log"].append({"type": "stderr", "text": line})
                    proc.wait()
                    processes[job_id]["done"] = True
                    processes[job_id]["exitCode"] = proc.returncode
                    print(f"[rsyncgui] interval done: pid={pid} exit={proc.returncode}", flush=True)
                except Exception as e:
                    processes[job_id]["log"].append({"type": "error", "text": str(e)})
                    processes[job_id]["done"] = True
                    processes[job_id]["exitCode"] = -1
                    print(f"[rsyncgui] interval error: pid={pid} err={e}", flush=True)
                finally:
                    if pw_file and os.path.exists(pw_file.name):
                        os.unlink(pw_file.name)
                for _ in range(secs):
                    if sf["stop"]:
                        break
                    time.sleep(1)

        t = threading.Thread(target=run_interval, args=(pair_id, interval_seconds, pair, stop_flag), daemon=True)
        t.start()
        interval_processes[pair_id]["thread"] = t


class Handler(http.server.BaseHTTPRequestHandler):
    # 修正点: クライアントが応答を読まなくなった接続を無期限に保持しないよう
    # ソケットにタイムアウトを設定(recv/sendの両方に効く)。
    # ThreadingHTTPServer化と合わせて、1接続がハングしても他の接続をブロックせず、
    # かつそのハングしたスレッド自体も一定時間で解放されるようにする。
    timeout = 30

    def log_message(self, format, *args):
        pass

    def handle_error(self, request, client_address):
        # 修正点: タイムアウトやクライアント切断は通常運用で頻発しうる想定内の事象。
        # フルスタックトレースをjournalに吐き続けるとログが肥大化するため、
        # これらは1行だけ記録し、それ以外の想定外エラーのみ通常通り出力する。
        exc_type = sys.exc_info()[0]
        if exc_type in (BrokenPipeError, ConnectionResetError, TimeoutError):
            print(f"[rsyncgui] connection closed early: {client_address}")
        else:
            super().handle_error(request, client_address)

    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")

    def _json_response(self, data, code=200):
        body = json.dumps(data).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self._cors()
        self.send_header("Content-Length", len(body))
        self.end_headers()
        self.wfile.write(body)

    def _file_response(self, filepath):
        ext = os.path.splitext(filepath)[1]
        mime = MIME.get(ext, "application/octet-stream")
        try:
            with open(filepath, "rb") as f:
                data = f.read()
            self.send_response(200)
            self.send_header("Content-Type", f"{mime}; charset=utf-8")
            self._cors()
            self.send_header("Content-Length", len(data))
            self.end_headers()
            self.wfile.write(data)
        except FileNotFoundError:
            self.send_response(404)
            self.end_headers()
            self.wfile.write(b"Not found")

    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        params = parse_qs(parsed.query)

        if path == "/api/browse":
            browse_path = params.get("path", ["/"])[0]
            self._browse(browse_path)
        elif path.startswith("/api/status/"):
            job_id = path.split("/")[-1]
            self._status(job_id)
        elif path == "/api/config":
            self._get_config()
        elif path == "/api/cron":
            self._get_cron()
        else:
            if path == "/":
                path = "/index.html"
            filepath = os.path.join(PUBLIC, path.lstrip("/"))
            self._file_response(filepath)

    def do_POST(self):
        parsed = urlparse(self.path)
        content_length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(content_length)

        if parsed.path == "/api/rsync":
            self._run_rsync(body)
        elif parsed.path == "/api/config":
            self._save_config(body)
        elif parsed.path == "/api/schedule/save":
            self._save_schedule(body)
        else:
            self.send_response(404)
            self.end_headers()

    def _browse(self, browse_path):
        browse_path = browse_path or "/"
        try:
            entries = os.scandir(browse_path)
            items = []
            for e in entries:
                if e.name.startswith("."):
                    continue
                if e.is_dir():
                    items.append({
                        "name": e.name,
                        "type": "dir",
                        "path": os.path.join(browse_path, e.name),
                    })
            items.sort(key=lambda x: x["name"])
            parent = os.path.dirname(browse_path.rstrip("/"))
            if not parent:
                parent = "/"
            self._json_response({"current": browse_path, "parent": parent, "items": items})
        except Exception as ex:
            self._json_response({"error": str(ex)}, 500)

    def _get_config(self):
        cfg = load_config()
        self._json_response(cfg)

    def _save_config(self, body):
        try:
            cfg = json.loads(body)
            save_config(cfg)
            self._json_response({"ok": True})
        except Exception as ex:
            self._json_response({"error": str(ex)}, 500)

    def _save_schedule(self, body):
        try:
            data = json.loads(body)
            pairs = data.get("pairs", [])
            sync_crontab(pairs)
            start_interval_scheduler(pairs)
            cfg = load_config()
            cfg["pairs"] = pairs
            save_config(cfg)
            self._json_response({"ok": True})
        except Exception as ex:
            self._json_response({"error": str(ex)}, 500)

    def _get_cron(self):
        content = get_crontab()
        lines = [l for l in content.splitlines() if CRON_TAG in l]

        cfg = load_config()
        interval_entries = []
        for pair in cfg.get("pairs", []):
            sched = pair.get("schedule", {})
            if sched.get("enabled") and sched.get("mode") == "interval":
                interval_val = sched.get("interval", 60)
                interval_unit = sched.get("intervalUnit", "s")
                unit_labels = {"s": "秒", "m": "分", "h": "時間"}
                interval_entries.append({
                    "id": pair.get("id", "?"),
                    "source": pair.get("source", ""),
                    "target": pair.get("target", ""),
                    "interval": interval_val,
                    "intervalUnit": interval_unit,
                    "intervalLabel": f"{interval_val}{unit_labels.get(interval_unit, '秒')}ごと"
                })

        self._json_response({"entries": lines, "intervals": interval_entries})

    def _run_rsync(self, body):
        try:
            job = json.loads(body)
            args, use_sshpass, password = build_rsync_cmd(job)
            job_id = f"job_{int(time.time() * 1000)}"

            cmd = "rsync " + " ".join(args)
            if use_sshpass:
                cmd = f"sshpass -f /tmp/rsyncgui_pw_{job_id} rsync " + " ".join(args)

            self._json_response({"id": job_id, "command": cmd})

            def run():
                pw_file = None
                try:
                    if use_sshpass:
                        pw_file = tempfile.NamedTemporaryFile(mode='w', delete=False, prefix='rsyncgui_pw_')
                        pw_file.write(password)
                        pw_file.close()
                        os.chmod(pw_file.name, 0o600)
                        cmd_list = ["sshpass", "-f", pw_file.name, "rsync"] + args
                    else:
                        cmd_list = ["rsync"] + args

                    proc = subprocess.Popen(
                        cmd_list,
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        text=True,
                    )
                    processes[job_id] = {"proc": proc, "log": [], "done": False, "exitCode": None}
                    for line in proc.stdout:
                        processes[job_id]["log"].append({"type": "stdout", "text": line})
                    for line in proc.stderr:
                        processes[job_id]["log"].append({"type": "stderr", "text": line})
                    proc.wait()
                    processes[job_id]["done"] = True
                    processes[job_id]["exitCode"] = proc.returncode
                except Exception as e:
                    processes[job_id]["log"].append({"type": "error", "text": str(e)})
                    processes[job_id]["done"] = True
                    processes[job_id]["exitCode"] = -1
                finally:
                    if pw_file and os.path.exists(pw_file.name):
                        os.unlink(pw_file.name)

            threading.Thread(target=run, daemon=True).start()
        except Exception as ex:
            self._json_response({"error": str(ex)}, 500)

    def _status(self, job_id):
        p = processes.get(job_id)
        if not p:
            self._json_response({"error": "not found"}, 404)
            return
        log = p["log"][:]
        p["log"] = []
        self._json_response({
            "done": p["done"],
            "exitCode": p["exitCode"],
            "log": log,
        })


def main():
    # 修正点: HTTPServer(同期・シングルスレッド)から ThreadingHTTPServer に変更。
    # 元の実装ではクライアント接続が1つハングするだけで他の全リクエストが
    # ブロックされ、サービス再起動時もSIGTERMで終了できずSIGKILLされていた。
    server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    server.daemon_threads = True
    print(f"rsyncGUI running at http://localhost:{PORT}")

    cfg = load_config()
    start_interval_scheduler(cfg.get("pairs", []))

    def shutdown(sig, frame):
        print("\nShutting down...")
        for pid in list(interval_processes.keys()):
            interval_processes[pid]["stop"] = True
        server.shutdown()
        sys.exit(0)

    signal.signal(signal.SIGINT, shutdown)
    signal.signal(signal.SIGTERM, shutdown)
    server.serve_forever()


if __name__ == "__main__":
    main()
PYEOF

echo "[4/6] Writing frontend..."
cat > "$INSTALL_DIR/public/index.html" << 'HTMLEOF'
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>rsyncGUI</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
  --bg: #0f1117;
  --surface: #1a1d27;
  --surface2: #232733;
  --border: #2e3345;
  --text: #e1e4ed;
  --text2: #8b90a0;
  --accent: #5b8af5;
  --accent2: #4a6fd8;
  --green: #3ecf8e;
  --red: #f44;
  --orange: #f5a623;
  --radius: 8px;
}
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
  font-size: 16px;
}
header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 28px;
  border-bottom: 1px solid var(--border);
  background: var(--surface);
}
header h1 {
  font-size: 24px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
header h1 span { color: var(--accent); }
.header-actions { display: flex; gap: 10px; }

.btn {
  padding: 8px 18px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface2);
  color: var(--text);
  font-size: 15px;
  cursor: pointer;
  transition: all 0.15s;
}
.btn:hover { background: var(--border); }
.btn-accent {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
  font-weight: 600;
}
.btn-accent:hover { background: var(--accent2); }
.btn-sm { padding: 5px 12px; font-size: 14px; }
.btn-danger { border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: rgba(244,68,68,0.1); }
.btn-icon { padding: 6px 8px; font-size: 18px; line-height: 1; }

main { padding: 24px 28px; }

.pairs-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 18px;
}
.pairs-header h2 { font-size: 18px; font-weight: 600; }

.pair-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  margin-bottom: 14px;
  overflow: hidden;
  transition: border-color 0.15s;
}
.pair-card:hover { border-color: #3a3f55; }
.pair-card.active { border-color: var(--accent); }

.pair-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 18px;
  background: var(--surface2);
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  user-select: none;
}
.pair-header:hover { background: #282c3a; }
.pair-title {
  font-size: 15px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 8px;
}
.pair-num {
  background: var(--accent);
  color: #fff;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 700;
}
.pair-status {
  font-size: 13px;
  padding: 3px 10px;
  border-radius: 4px;
  font-weight: 600;
}
.pair-status.idle { background: var(--surface); color: var(--text2); }
.pair-status.running { background: rgba(91,138,245,0.15); color: var(--accent); }
.pair-status.done { background: rgba(62,207,142,0.15); color: var(--green); }
.pair-status.error { background: rgba(244,68,68,0.15); color: var(--red); }

.pair-body { padding: 18px; }
.pair-body.collapsed { display: none; }

.path-row {
  display: flex;
  gap: 14px;
  margin-bottom: 18px;
}
.path-group {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.path-group label {
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text2);
}
.path-input {
  display: flex;
  gap: 0;
}
.path-input input {
  flex: 1;
  padding: 9px 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius) 0 0 var(--radius);
  color: var(--text);
  font-size: 15px;
  font-family: 'SF Mono', 'Fira Code', monospace;
  outline: none;
}
.path-input input:focus { border-color: var(--accent); }
.path-input button {
  padding: 9px 12px;
  background: var(--surface2);
  border: 1px solid var(--border);
  border-left: none;
  border-radius: 0 var(--radius) var(--radius) 0;
  color: var(--text2);
  cursor: pointer;
  font-size: 16px;
}
.path-input button:hover { background: var(--border); color: var(--text); }

.arrow-center {
  display: flex;
  align-items: flex-end;
  padding-bottom: 9px;
  color: var(--text2);
  font-size: 24px;
}

.mode-row {
  display: flex;
  align-items: center;
  gap: 18px;
  margin-bottom: 18px;
  flex-wrap: wrap;
}
.mode-group {
  display: flex;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}
.mode-btn {
  padding: 9px 24px;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  border: none;
  background: transparent;
  color: var(--text2);
  transition: all 0.15s;
}
.mode-btn.active {
  background: var(--accent);
  color: #fff;
}
.mode-btn:hover:not(.active) { background: var(--surface2); color: var(--text); }

.options-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 4px;
  margin-bottom: 18px;
}
.option-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.1s;
}
.option-item:hover { background: var(--surface2); }
.option-item input[type="checkbox"] {
  accent-color: var(--accent);
  width: 17px;
  height: 17px;
  flex-shrink: 0;
}
.option-item label {
  font-size: 14px;
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
}
.option-item .opt-name { font-weight: 600; font-family: 'SF Mono','Fira Code',monospace; color: var(--accent); min-width: 28px; }
.option-item .opt-desc { color: var(--text2); font-size: 13px; }
.option-item.danger-opt .opt-name { color: var(--red); }
.option-item.danger-opt { border: 1px solid transparent; }
.option-item.danger-opt:hover { border-color: rgba(255,68,68,0.3); background: rgba(255,68,68,0.05); }

.delete-warning {
  display: none;
  margin-bottom: 12px;
  padding: 9px 14px;
  background: rgba(244,68,68,0.1);
  border: 1px solid rgba(244,68,68,0.4);
  border-radius: var(--radius);
  color: var(--red);
  font-size: 14px;
  font-weight: 600;
}
.delete-warning.show { display: block; }

.backup-dir-row {
  display: none;
  margin-bottom: 14px;
}
.backup-dir-row.show { display: flex; flex-direction: column; gap: 5px; }
.backup-dir-row label {
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text2);
}
.backup-dir-row input {
  padding: 8px 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 15px;
  font-family: 'SF Mono','Fira Code',monospace;
  outline: none;
}
.backup-dir-row input:focus { border-color: var(--accent); }

.ssh-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 14px;
}
.toggle {
  position: relative;
  width: 40px;
  height: 22px;
  background: var(--border);
  border-radius: 11px;
  cursor: pointer;
  transition: background 0.2s;
}
.toggle.active { background: var(--accent); }
.toggle::after {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 16px;
  height: 16px;
  background: #fff;
  border-radius: 50%;
  transition: transform 0.2s;
}
.toggle.active::after { transform: translateX(18px); }
.ssh-toggle span { font-size: 14px; font-weight: 600; }

.ssh-config {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
  gap: 10px;
  margin-bottom: 18px;
  padding: 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.ssh-config.hidden { display: none; }
.ssh-field { display: flex; flex-direction: column; gap: 5px; }
.ssh-field label {
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text2);
}
.ssh-field input {
  padding: 8px 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 15px;
  outline: none;
}
.ssh-field input:focus { border-color: var(--accent); }

.exclude-row {
  margin-bottom: 18px;
}
.exclude-row label {
  font-size: 13px;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--text2);
  display: block;
  margin-bottom: 5px;
}
.exclude-row textarea {
  width: 100%;
  height: 56px;
  padding: 9px 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 14px;
  font-family: 'SF Mono', 'Fira Code', monospace;
  resize: vertical;
  outline: none;
}
.exclude-row textarea:focus { border-color: var(--accent); }

.pair-actions {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}
.pair-actions-left { display: flex; gap: 10px; }

.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  backdrop-filter: blur(4px);
}
.modal-overlay.hidden { display: none; }
.modal {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  width: 620px;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 22px;
  border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 18px; font-weight: 600; }
.modal-close {
  background: none;
  border: none;
  color: var(--text2);
  font-size: 24px;
  cursor: pointer;
}
.modal-close:hover { color: var(--text); }
.modal-body { flex: 1; overflow-y: auto; padding: 14px 22px; }
.modal-footer {
  padding: 14px 22px;
  border-top: 1px solid var(--border);
  display: flex;
  justify-content: flex-end;
  gap: 10px;
}

.breadcrumb {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 9px 0;
  font-size: 15px;
  flex-wrap: wrap;
}
.breadcrumb-item {
  color: var(--accent);
  cursor: pointer;
  padding: 3px 8px;
  border-radius: 4px;
}
.breadcrumb-item:hover { background: rgba(91,138,245,0.1); }
.breadcrumb-sep { color: var(--text2); }

.folder-list { list-style: none; }
.folder-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 14px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 15px;
  transition: background 0.1s;
}
.folder-item:hover { background: var(--surface2); }
.folder-item .icon { font-size: 18px; }
.folder-item .name { flex: 1; }
.folder-item .path { color: var(--text2); font-size: 13px; font-family: monospace; }
.folder-item.selected { background: rgba(91,138,245,0.15); border: 1px solid var(--accent); }

.log-panel {
  margin-top: 28px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  overflow: hidden;
}
.log-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 18px;
  background: var(--surface2);
  border-bottom: 1px solid var(--border);
}
.log-header h3 { font-size: 15px; font-weight: 600; }
.log-body {
  padding: 14px 18px;
  max-height: 320px;
  overflow-y: auto;
  font-family: 'SF Mono', 'Fira Code', monospace;
  font-size: 14px;
  line-height: 1.6;
  white-space: pre-wrap;
  color: var(--text2);
}
.log-body .stdout { color: var(--text); }
.log-body .stderr { color: var(--orange); }
.log-body .error { color: var(--red); }

.notification {
  position: fixed;
  top: 18px;
  right: 18px;
  padding: 14px 22px;
  border-radius: var(--radius);
  font-size: 15px;
  font-weight: 600;
  z-index: 2000;
  animation: slideIn 0.3s ease;
}
@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}
.notification.success { background: rgba(62,207,142,0.15); color: var(--green); border: 1px solid var(--green); }
.notification.error { background: rgba(244,68,68,0.15); color: var(--red); border: 1px solid var(--red); }

.empty-state {
  text-align: center;
  padding: 44px;
  color: var(--text2);
}
.empty-state .icon { font-size: 52px; margin-bottom: 14px; }
.empty-state p { font-size: 16px; }

.schedule-section {
  margin-bottom: 18px;
  padding: 14px;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.schedule-section.hidden { display: none; }
.schedule-row {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 8px;
}
.schedule-row:last-child { margin-bottom: 0; }
.schedule-row label {
  font-size: 14px;
  font-weight: 600;
  min-width: 80px;
}
.schedule-row input[type="time"],
.schedule-row input[type="number"],
.schedule-row select {
  padding: 6px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 14px;
  outline: none;
}
.schedule-row input[type="time"]:focus,
.schedule-row input[type="number"]:focus,
.schedule-row select:focus { border-color: var(--accent); }
.schedule-row .days {
  display: flex;
  gap: 5px;
}
.schedule-row .day-btn {
  width: 36px;
  height: 30px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--surface);
  color: var(--text2);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.15s;
}
.schedule-row .day-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.schedule-row .day-btn:hover { border-color: var(--accent); }
.schedule-desc {
  font-size: 13px;
  color: var(--text2);
  margin-top: 10px;
  padding: 8px 10px;
  background: var(--surface);
  border-radius: 6px;
}
.schedule-toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}
.schedule-select {
  padding: 6px 12px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 14px;
  outline: none;
}
.schedule-select:focus { border-color: var(--accent); }
.schedule-badge {
  font-size: 13px;
  padding: 3px 10px;
  border-radius: 4px;
  font-weight: 600;
  background: rgba(62,207,142,0.15);
  color: var(--green);
}
.schedule-badge.off {
  background: var(--surface);
  color: var(--text2);
}
.interval-group {
  display: flex;
  gap: 8px;
  align-items: center;
}
.interval-group input[type="number"] {
  width: 80px;
}
</style>
</head>
<body>
<header>
  <h1><span>⚡</span> rsyncGUI</h1>
  <div class="header-actions">
    <button class="btn btn-sm" onclick="addPair()">+ ペア追加</button>
    <button class="btn btn-sm" onclick="exportConfig()">📥 エクスポート</button>
    <button class="btn btn-sm" onclick="document.getElementById('importFile').click()">📤 インポート</button>
    <input type="file" id="importFile" accept=".json" style="display:none" onchange="importConfig(event)">
    <button class="btn btn-sm" onclick="saveSchedule()" style="border-color:var(--green);color:var(--green)">⏰ スケジュール登録</button>
    <button class="btn btn-sm" onclick="showScheduleCheck()" style="border-color:var(--orange);color:var(--orange)">📋 スケジュール確認</button>
    <button class="btn btn-sm btn-accent" onclick="runAll()">▶ 全実行</button>
  </div>
</header>

<main>
  <div id="pairs"></div>
  <div id="logPanel" class="log-panel" style="display:none">
    <div class="log-header">
      <h3>📋 実行ログ</h3>
      <button class="btn btn-sm" onclick="clearLog()">クリア</button>
    </div>
    <div class="log-body" id="logBody"></div>
  </div>
</main>

<div class="modal-overlay hidden" id="folderModal">
  <div class="modal">
    <div class="modal-header">
      <h3>📁 フォルダを選択</h3>
      <button class="modal-close" onclick="closeModal()">×</button>
    </div>
    <div class="modal-body">
      <div class="breadcrumb" id="breadcrumb"></div>
      <ul class="folder-list" id="folderList"></ul>
    </div>
    <div class="modal-footer">
      <button class="btn" onclick="closeModal()">キャンセル</button>
      <button class="btn btn-accent" id="selectBtn" onclick="selectFolder()">選択</button>
    </div>
  </div>
</div>

<div class="modal-overlay hidden" id="scheduleModal">
  <div class="modal" style="width:700px">
    <div class="modal-header">
      <h3>📋 スケジュール確認</h3>
      <button class="modal-close" onclick="closeScheduleModal()">×</button>
    </div>
    <div class="modal-body" id="scheduleModalBody">
      <div style="text-align:center;color:var(--text2);padding:20px">読み込み中...</div>
    </div>
    <div class="modal-footer">
      <button class="btn" onclick="closeScheduleModal()">閉じる</button>
    </div>
  </div>
</div>

<script>
const RSYNC_OPTIONS = [
  { key: 'archive',          short: 'a',    desc: 'アーカイブモード(権限・タイムスタンプ保持)' },
  { key: 'verbose',          short: 'v',    desc: '詳細出力' },
  { key: 'compress',         short: 'z',    desc: '転送時圧縮' },
  { key: 'recursive',        short: 'r',    desc: 'サブディレクトリ再帰' },
  { key: 'times',            short: 't',    desc: 'タイムスタンプ維持' },
  { key: 'perms',            short: 'p',    desc: 'パーミッション維持' },
  { key: 'owner',            short: 'o',    desc: 'オーナー情報維持' },
  { key: 'group',            short: 'g',    desc: 'グループ情報維持' },
  { key: 'links',            short: 'l',    desc: 'シンボリックリンクを複製' },
  { key: 'hard-links',       short: 'H',    desc: 'ハードリンクを複製' },
  { key: 'partial',          short: null,   desc: '中断ファイルを保持(転送再開用)' },
  { key: 'progress',         short: null,   desc: '進捗表示' },
  { key: 'human-readable',   short: 'h',    desc: '人間が読めるサイズ表示(rsync 3.x以降)' },
  { key: 'checksum',         short: 'c',    desc: 'チェックサムで比較(タイムスタンプ無視)' },
  { key: 'itemize-changes',  short: 'i',    desc: '変更ファイルの詳細表示' },
  { key: 'stats',            short: null,   desc: '転送統計情報を表示' },
  { key: 'ignore-existing',  short: null,   desc: '転送先に既存ファイルがあればスキップ' },
  { key: 'update',           short: 'u',    desc: '転送元より新しいファイルのみ転送' },
  { key: 'delete',           short: null,   desc: 'ターゲット側の不要ファイルを削除', danger: true },
  { key: 'backup',           short: 'b',    desc: '上書き前にバックアップを作成' },
];

let pairs = [];
let nextId = 1;
let currentPairId = null;
let currentInputId = null;
let currentPath = '/';
let selectedFolder = null;
let pollingTimers = {};

const DAY_NAMES = ['日','月','火','水','木','金','土'];

const SYNC_DEFAULTS  = { archive: true, verbose: true, compress: true, progress: true };
const COPY_DEFAULTS  = { recursive: true, verbose: true, progress: true };

function createDefaultPair() {
  return {
    id: nextId++,
    source: '',
    target: '',
    mode: 'sync',
    sshEnabled: false,
    ssh: { host: '', port: '22', user: '', password: '', key: '' },
    options: { ...SYNC_DEFAULTS },
    'backup-dir': '',
    exclude: '',
    dryRun: false,
    status: 'idle',
    collapsed: false,
    schedule: { enabled: false, mode: 'time', time: '02:00', days: [1,2,3,4,5,6,0], interval: 60, intervalUnit: 's' },
  };
}

function ensurePair(p) {
  if (!p.ssh) p.ssh = { host: '', port: '22', user: '', password: '', key: '' };
  if (!p.options) p.options = { ...SYNC_DEFAULTS };
  if (!p.schedule) p.schedule = { enabled: false, mode: 'time', time: '02:00', days: [1,2,3,4,5,6,0], interval: 60, intervalUnit: 's' };
  if (!p.schedule.mode) p.schedule.mode = 'time';
  if (p.schedule.interval === undefined) p.schedule.interval = 60;
  if (!p.schedule.intervalUnit) p.schedule.intervalUnit = 's';
  if (p['backup-dir'] === undefined) p['backup-dir'] = '';
  return p;
}

function addPair() {
  pairs.push(createDefaultPair());
  render();
}

function removePair(id) {
  pairs = pairs.filter(p => p.id !== id);
  render();
}

function toggleCollapse(id) {
  const p = pairs.find(x => x.id === id);
  if (p) p.collapsed = !p.collapsed;
  render();
}

function setMode(id, mode) {
  const p = pairs.find(x => x.id === id);
  if (!p) return;
  p.mode = mode;
  if (mode === 'sync') {
    Object.assign(p.options, SYNC_DEFAULTS);
    p.options.recursive = false;
  } else {
    Object.assign(p.options, COPY_DEFAULTS);
    p.options.archive = false;
    p.options.compress = false;
  }
  render();
}

function toggleOption(id, key) {
  const p = pairs.find(x => x.id === id);
  if (!p) return;
  p.options[key] = !p.options[key];
  if (key === 'delete' || key === 'backup') render();
}

function toggleSSH(id) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.sshEnabled = !p.sshEnabled; render(); }
}

function toggleSchedule(id) {
  const p = pairs.find(x => x.id === id);
  if (p) {
    p.schedule.enabled = !p.schedule.enabled;
    render();
  }
}

function setScheduleMode(id, mode) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.schedule.mode = mode; render(); }
}

function setScheduleTime(id, time) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.schedule.time = time; render(); }
}

function setScheduleInterval(id, value) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.schedule.interval = parseInt(value) || 1; render(); }
}

function setScheduleIntervalUnit(id, unit) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.schedule.intervalUnit = unit; render(); }
}

function toggleScheduleDay(id, day) {
  const p = pairs.find(x => x.id === id);
  if (!p) return;
  const s = p.schedule;
  const idx = s.days.indexOf(day);
  if (idx >= 0) s.days.splice(idx, 1);
  else s.days.push(day);
  s.days.sort();
  render();
}

function formatInterval(interval, unit) {
  const unitLabels = { 's': '秒', 'm': '分', 'h': '時間' };
  return interval + unitLabels[unit] + 'ごと';
}

function getScheduleDesc(p) {
  if (!p.schedule.enabled) return 'スケジュール: OFF';
  const s = p.schedule;
  if (s.mode === 'time') {
    if (!s.days.length) return '未設定';
    const dayStr = s.days.map(d => DAY_NAMES[d]).join('・');
    return dayStr + ' ' + s.time;
  } else {
    return formatInterval(s.interval, s.intervalUnit);
  }
}

function updateField(id, field, value) {
  const p = pairs.find(x => x.id === id);
  if (!p) return;
  if (field.startsWith('ssh.')) {
    p.ssh[field.slice(4)] = value;
  } else {
    p[field] = value;
  }
}

function openFolderChooser(pairId, inputType) {
  currentPairId = pairId;
  currentInputId = inputType;
  const p = pairs.find(x => x.id === pairId);
  currentPath = (inputType === 'source' ? p.source : p.target) || '/';
  if (!currentPath.endsWith('/')) currentPath += '/';
  selectedFolder = null;
  document.getElementById('folderModal').classList.remove('hidden');
  browseTo(currentPath);
}

function closeModal() {
  document.getElementById('folderModal').classList.add('hidden');
}

function selectFolder() {
  if (!selectedFolder) return;
  const p = pairs.find(x => x.id === currentPairId);
  if (!p) return;
  if (currentInputId === 'source') p.source = selectedFolder;
  else p.target = selectedFolder;
  closeModal();
  render();
}

async function browseTo(dirPath) {
  currentPath = dirPath;
  const res = await fetch('/api/browse?path=' + encodeURIComponent(dirPath));
  const data = await res.json();
  if (data.error) return;

  const bc = document.getElementById('breadcrumb');
  const parts = data.current.split('/').filter(Boolean);
  let html = '<span class="breadcrumb-item" onclick="browseTo(\'/\')">/</span>';
  let accumulated = '';
  for (const part of parts) {
    accumulated += '/' + part;
    const p = accumulated;
    html += '<span class="breadcrumb-sep">/</span>';
    html += '<span class="breadcrumb-item" onclick="browseTo(\'' + p.replace(/'/g, "\\'") + '\')">' + part + '</span>';
  }
  bc.innerHTML = html;

  const list = document.getElementById('folderList');
  let items = '';
  if (data.parent && data.parent !== data.current) {
    items += '<li class="folder-item" onclick="browseTo(\'' + data.parent.replace(/'/g, "\\'") + '\')">';
    items += '<span class="icon">⬆</span><span class="name">..</span></li>';
  }
  for (const item of data.items) {
    const sel = selectedFolder === item.path ? ' selected' : '';
    items += '<li class="folder-item' + sel + '" onclick="selectItem(this, \'' + item.path.replace(/'/g, "\\'") + '\')">';
    items += '<span class="icon">📁</span><span class="name">' + item.name + '</span>';
    items += '<span class="path">' + item.path + '</span></li>';
  }
  if (!data.items.length && !data.parent) {
    items = '<li class="folder-item"><span class="name">(フォルダなし)</span></li>';
  }
  list.innerHTML = items;
}

function selectItem(el, path) {
  selectedFolder = path;
  document.querySelectorAll('.folder-item').forEach(x => x.classList.remove('selected'));
  el.classList.add('selected');
}

async function runPair(id) {
  const p = pairs.find(x => x.id === id);
  if (!p || !p.source || !p.target) {
    notify('ソースとターゲットを指定してください', 'error');
    return;
  }
  p.status = 'running';
  render();
  try {
    const opts = { ...p.options };
    const body = {
      source: p.source,
      target: p.target,
      mode: p.mode,
      options: opts,
      'backup-dir': p['backup-dir'] || '',
      ssh: p.sshEnabled ? p.ssh : null,
      dryRun: p.dryRun,
      exclude: p.exclude,
    };
    const res = await fetch('/api/rsync', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    });
    const data = await res.json();
    showLog('▶ ' + data.command);
    pollStatus(id, data.id);
  } catch (e) {
    p.status = 'error';
    render();
    notify('エラー: ' + e.message, 'error');
  }
}

function pollStatus(pairId, jobId) {
  const timer = setInterval(async () => {
    try {
      const res = await fetch('/api/status/' + jobId);
      const data = await res.json();
      for (const line of data.log) {
        showLog(line.text, line.type);
      }
      data.log.length = 0;
      if (data.done) {
        clearInterval(timer);
        const p = pairs.find(x => x.id === pairId);
        if (p) p.status = data.exitCode === 0 ? 'done' : 'error';
        render();
        notify(data.exitCode === 0 ? '完了' : 'エラー終了', data.exitCode === 0 ? 'success' : 'error');
      }
    } catch (e) {
      clearInterval(timer);
    }
  }, 500);
  pollingTimers[pairId] = timer;
}

function runAll() {
  for (const p of pairs) {
    if (p.source && p.target) runPair(p.id);
  }
}

function showLog(text, type) {
  const panel = document.getElementById('logPanel');
  const body = document.getElementById('logBody');
  panel.style.display = 'block';
  const span = document.createElement('span');
  span.className = type || '';
  span.textContent = text;
  body.appendChild(span);
  body.appendChild(document.createElement('br'));
  body.scrollTop = body.scrollHeight;
}

function clearLog() {
  document.getElementById('logBody').innerHTML = '';
  document.getElementById('logPanel').style.display = 'none';
}

function notify(msg, type) {
  const el = document.createElement('div');
  el.className = 'notification ' + type;
  el.textContent = msg;
  document.body.appendChild(el);
  setTimeout(() => el.remove(), 3000);
}

function render() {
  const container = document.getElementById('pairs');
  if (!pairs.length) {
    container.innerHTML = '<div class="empty-state"><div class="icon">📂</div><p>ペアを追加してrsyncを開始しましょう</p></div>';
    return;
  }
  let html = '';
  for (const p of pairs) {
    html += renderPair(p);
  }
  container.innerHTML = html;
}

function renderPair(p) {
  p = ensurePair(p);
  const statusLabel = { idle: '待機中', running: '実行中', done: '完了', error: 'エラー' }[p.status];
  const modeBtn = (m, label) => `<button class="mode-btn${p.mode === m ? ' active' : ''}" onclick="setMode(${p.id},'${m}')">${label}</button>`;

  const optItem = (o) => {
    const flagLabel = o.short ? `-${o.short} / --${o.key}` : `--${o.key}`;
    const dangerClass = o.danger ? ' danger-opt' : '';
    return `<label class="option-item${dangerClass}">` +
      `<input type="checkbox" ${p.options[o.key] ? 'checked' : ''} onchange="toggleOption(${p.id},'${o.key}')">` +
      `<label><span class="opt-name">${flagLabel}</span> <span class="opt-desc">${o.desc}</span></label>` +
      `</label>`;
  };

  const showDeleteWarning = p.options['delete'] ? 'show' : '';
  const showBackupDir    = p.options['backup']  ? 'show' : '';

  const scheduleTimeInput = p.schedule.mode === 'time'
    ? `<input type="time" value="${esc(p.schedule.time)}" onchange="setScheduleTime(${p.id},this.value)">`
    : `<div class="interval-group">
        <input type="number" min="1" value="${p.schedule.interval}" onchange="setScheduleInterval(${p.id},this.value)">
        <select onchange="setScheduleIntervalUnit(${p.id},this.value)">
          <option value="s"${p.schedule.intervalUnit === 's' ? ' selected' : ''}>秒</option>
          <option value="m"${p.schedule.intervalUnit === 'm' ? ' selected' : ''}>分</option>
          <option value="h"${p.schedule.intervalUnit === 'h' ? ' selected' : ''}>時間</option>
        </select>
       </div>`;

  const scheduleTimeLabel = p.schedule.mode === 'time' ? '時刻' : '間隔';

  const daysHtml = p.schedule.mode === 'time'
    ? `<div class="schedule-row">
        <label>曜日</label>
        <div class="days">
          ${DAY_NAMES.map((d,i) => '<div class="day-btn' + (p.schedule.days.includes(i) ? ' active' : '') + '" onclick="toggleScheduleDay(' + p.id + ',' + i + ')">' + d + '</div>').join('')}
        </div>
       </div>`
    : '';

  return `
  <div class="pair-card ${p.status === 'running' ? 'active' : ''}">
    <div class="pair-header" onclick="toggleCollapse(${p.id})">
      <div class="pair-title">
        <span class="pair-num">${p.id}</span>
        ${p.source ? '<span style="color:var(--text2);font-size:14px">' + shorten(p.source) + '</span>' : '<span style="color:var(--text2);font-size:14px">未設定</span>'}
        <span style="color:var(--text2)">→</span>
        ${p.target ? '<span style="color:var(--text2);font-size:14px">' + shorten(p.target) + '</span>' : '<span style="color:var(--text2);font-size:14px">未設定</span>'}
      </div>
      <div style="display:flex;align-items:center;gap:10px">
        ${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : ''}
        <span class="pair-status ${p.status}">${statusLabel}</span>
      </div>
    </div>
    <div class="pair-body${p.collapsed ? ' collapsed' : ''}">
      <div class="path-row">
        <div class="path-group">
          <label>ソースフォルダ</label>
          <div class="path-input">
            <input type="text" value="${esc(p.source)}" placeholder="/path/to/source" onchange="updateField(${p.id},'source',this.value)">
            <button onclick="openFolderChooser(${p.id},'source')">📁</button>
          </div>
        </div>
        <div class="arrow-center">→</div>
        <div class="path-group">
          <label>ターゲットフォルダ</label>
          <div class="path-input">
            <input type="text" value="${esc(p.target)}" placeholder="/path/to/target" onchange="updateField(${p.id},'target',this.value)">
            <button onclick="openFolderChooser(${p.id},'target')">📁</button>
          </div>
        </div>
      </div>
      <div class="mode-row">
        <div class="mode-group">
          ${modeBtn('copy', '📋 Copy')}
          ${modeBtn('sync', '🔄 Sync')}
        </div>
        <div class="ssh-toggle">
          <div class="toggle${p.sshEnabled ? ' active' : ''}" onclick="toggleSSH(${p.id})"></div>
          <span>SSH リモート接続</span>
        </div>
      </div>
      <div class="ssh-config${p.sshEnabled ? '' : ' hidden'}">
        <div class="ssh-field">
          <label>ホスト名 / IP</label>
          <input type="text" value="${esc(p.ssh.host)}" placeholder="192.168.1.100" onchange="updateField(${p.id},'ssh.host',this.value)">
        </div>
        <div class="ssh-field">
          <label>ポート</label>
          <input type="text" value="${esc(p.ssh.port)}" placeholder="22" onchange="updateField(${p.id},'ssh.port',this.value)">
        </div>
        <div class="ssh-field">
          <label>ユーザー名</label>
          <input type="text" value="${esc(p.ssh.user)}" placeholder="root" onchange="updateField(${p.id},'ssh.user',this.value)">
        </div>
        <div class="ssh-field">
          <label>パスワード</label>
          <input type="password" value="${esc(p.ssh.password)}" placeholder="(パスワード認証の場合)" onchange="updateField(${p.id},'ssh.password',this.value)">
        </div>
        <div class="ssh-field">
          <label>SSH秘密鍵パス</label>
          <input type="text" value="${esc(p.ssh.key)}" placeholder="~/.ssh/id_rsa" onchange="updateField(${p.id},'ssh.key',this.value)">
        </div>
      </div>
      <div class="options-grid">
        ${RSYNC_OPTIONS.map(o => optItem(o)).join('')}
      </div>

      <div class="delete-warning ${showDeleteWarning}">
        ⚠️ --delete が有効です。ターゲット側にあってソース側にないファイルが削除されます。
      </div>

      <div class="backup-dir-row ${showBackupDir}">
        <label>バックアップ先ディレクトリ(--backup-dir)</label>
        <input type="text" value="${esc(p['backup-dir'])}" placeholder="/path/to/backup (空白時はターゲットと同じ場所に ~suffix)"
          onchange="updateField(${p.id},'backup-dir',this.value)">
      </div>

      <div class="exclude-row">
        <label>除外パターン(1行1パターン)</label>
        <textarea placeholder="*.log&#10;.git/&#10;node_modules/" onchange="updateField(${p.id},'exclude',this.value)">${esc(p.exclude)}</textarea>
      </div>
      <div class="schedule-section">
        <div class="schedule-toggle">
          <div class="toggle${p.schedule.enabled ? ' active' : ''}" onclick="toggleSchedule(${p.id})"></div>
          <span style="font-size:14px;font-weight:600">スケジュール実行</span>
          ${p.schedule.enabled ? '<span class="schedule-badge">' + getScheduleDesc(p) + '</span>' : '<span class="schedule-badge off">OFF</span>'}
        </div>
        <div class="${p.schedule.enabled ? '' : 'hidden'}" style="margin-top:14px">
          <div class="schedule-row">
            <label>モード</label>
            <select class="schedule-select" onchange="setScheduleMode(${p.id},this.value)">
              <option value="time"${p.schedule.mode === 'time' ? ' selected' : ''}>指定時刻</option>
              <option value="interval"${p.schedule.mode === 'interval' ? ' selected' : ''}>指定間隔</option>
            </select>
          </div>
          <div class="schedule-row">
            <label>${scheduleTimeLabel}</label>
            ${scheduleTimeInput}
          </div>
          ${daysHtml}
          <div class="schedule-desc">${getScheduleDesc(p)}</div>
        </div>
      </div>
      <div class="pair-actions">
        <div class="pair-actions-left">
          <button class="btn btn-accent" onclick="runPairForce(${p.id})" ${p.status === 'running' ? 'disabled' : ''}>▶ 実行</button>
          <button class="btn" onclick="runPairDry(${p.id})">🔍 ドライラン</button>
        </div>
        <button class="btn btn-danger btn-sm" onclick="removePair(${p.id})">🗑 削除</button>
      </div>
    </div>
  </div>`;
}

function runPairDry(id) {
  const p = pairs.find(x => x.id === id);
  if (p) {
    p.dryRun = true;
    runPair(id).then(() => {
      p.dryRun = false;
      render();
    });
  }
}

function runPairForce(id) {
  const p = pairs.find(x => x.id === id);
  if (p) { p.dryRun = false; render(); runPair(id); }
}

function shorten(s) {
  if (!s) return '';
  const parts = s.split('/').filter(Boolean);
  if (parts.length <= 2) return s;
  return '…/' + parts.slice(-2).join('/');
}

function esc(s) {
  return (s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

function exportConfig() {
  const data = {
    pairs: pairs.map(p => {
      const clean = { ...p };
      delete clean.status;
      delete clean.collapsed;
      return clean;
    }),
  };
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = 'rsyncgui-config-' + new Date().toISOString().slice(0,10) + '.json';
  a.click();
  URL.revokeObjectURL(url);
  notify('エクスポート完了', 'success');
}

function importConfig(event) {
  const file = event.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = function(e) {
    try {
      const data = JSON.parse(e.target.result);
      if (data.pairs && Array.isArray(data.pairs)) {
        pairs = data.pairs.map(ensurePair);
        nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
        render();
        notify('インポート完了: ' + pairs.length + 'ペア読み込み', 'success');
      } else {
        notify('無効な設定ファイルです', 'error');
      }
    } catch (err) {
      notify('パースエラー: ' + err.message, 'error');
    }
  };
  reader.readAsText(file);
  event.target.value = '';
}

async function saveSchedule() {
  try {
    const res = await fetch('/api/schedule/save', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ pairs: pairs }),
    });
    const data = await res.json();
    if (data.ok) {
      notify('スケジュールを登録しました', 'success');
    } else {
      notify('登録エラー: ' + (data.error || '不明'), 'error');
    }
  } catch (e) {
    notify('通信エラー: ' + e.message, 'error');
  }
}

function showScheduleCheck() {
  document.getElementById('scheduleModal').classList.remove('hidden');
  loadScheduleCheck();
}

function closeScheduleModal() {
  document.getElementById('scheduleModal').classList.add('hidden');
}

async function loadScheduleCheck() {
  const body = document.getElementById('scheduleModalBody');
  body.innerHTML = '<div style="text-align:center;color:var(--text2);padding:20px">読み込み中...</div>';
  try {
    const res = await fetch('/api/cron');
    const data = await res.json();
    const entries = data.entries || [];
    const intervals = data.intervals || [];

    const rsyncEntries = entries.filter(e => e.includes('rsync') || e.includes('rsyncgui'));
    const otherEntries = entries.filter(e => !e.includes('rsync') && !e.includes('rsyncgui'));

    let html = '';

    html += '<div style="margin-bottom:20px">';
    html += '<h4 style="font-size:15px;font-weight:600;color:var(--green);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">🔄 rsync関連スケジュール (' + (rsyncEntries.length + intervals.length) + '件)</h4>';
    if (rsyncEntries.length === 0 && intervals.length === 0) {
      html += '<div style="color:var(--text2);font-size:14px;padding:10px">登録なし</div>';
    } else {
      html += '<div style="font-family:monospace;font-size:13px;line-height:1.8">';
      for (const entry of rsyncEntries) {
        const parts = entry.split(/\s+/);
        const cronTime = parts.slice(0, 5).join(' ');
        const comment = entry.match(/# rsyncgui-managed id=(\d+)/);
        const pairId = comment ? comment[1] : '?';
        const pair = pairs.find(p => p.id === parseInt(pairId));
        const label = pair ? (shorten(pair.source) + ' → ' + shorten(pair.target)) : '(ペア#' + pairId + ')';

        html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface)">';
        html += '<span style="color:var(--accent)">' + cronTime + '</span> ';
        html += '<span style="color:var(--text)">' + label + '</span>';
        html += '</div>';
      }
      for (const iv of intervals) {
        const pair = pairs.find(p => p.id === iv.id);
        const label = pair ? (shorten(pair.source) + ' → ' + shorten(pair.target)) : '(ペア#' + iv.id + ')';

        html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface)">';
        html += '<span style="color:var(--orange)">' + iv.intervalLabel + '</span> ';
        html += '<span style="color:var(--text)">' + label + '</span>';
        html += '</div>';
      }
      html += '</div>';
    }
    html += '</div>';

    html += '<div>';
    html += '<h4 style="font-size:15px;font-weight:600;color:var(--text2);margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:6px">⚙️ その他のスケジュール (' + otherEntries.length + '件)</h4>';
    if (otherEntries.length === 0) {
      html += '<div style="color:var(--text2);font-size:14px;padding:10px">登録なし</div>';
    } else {
      html += '<div style="font-family:monospace;font-size:13px;line-height:1.8">';
      for (const entry of otherEntries) {
        html += '<div style="padding:6px 10px;border-radius:4px;margin-bottom:4px;background:var(--surface);word-break:break-all">';
        html += '<span style="color:var(--text)">' + escHtml(entry) + '</span>';
        html += '</div>';
      }
      html += '</div>';
    }
    html += '</div>';

    body.innerHTML = html;
  } catch (e) {
    body.innerHTML = '<div style="color:var(--red);padding:20px">読み込みエラー: ' + e.message + '</div>';
  }
}

function escHtml(s) {
  return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

async function loadConfig() {
  try {
    const res = await fetch('/api/config');
    const cfg = await res.json();
    if (cfg.pairs && cfg.pairs.length) {
      pairs = cfg.pairs.map(ensurePair);
      nextId = Math.max(...pairs.map(p => p.id), 0) + 1;
    } else {
      addPair();
    }
  } catch (e) {
    addPair();
  }
  render();
}

loadConfig();
</script>
</body>
</html>

HTMLEOF

echo "[5/6] Creating systemd service..."
cat > /etc/systemd/system/${SERVICE_NAME}.service << SVCEOF
[Unit]
Description=rsyncGUI - Web-based rsync interface
After=network.target

[Service]
Type=simple
WorkingDirectory=${INSTALL_DIR}
ExecStart=/usr/bin/python3 ${INSTALL_DIR}/server.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
SVCEOF

echo "[6/6] Starting service..."
systemctl daemon-reload
systemctl enable ${SERVICE_NAME}
systemctl restart ${SERVICE_NAME}

echo ""
echo "=== Done! ==="
echo "rsyncGUI is running at http://localhost:${PORT}"
echo ""
echo "Commands:"
echo "  systemctl status ${SERVICE_NAME}"
echo "  systemctl restart ${SERVICE_NAME}"
echo "  journalctl -u ${SERVICE_NAME} -f"

アンインストール方法

# サービス停止・無効化
systemctl stop rsyncgui
systemctl disable rsyncgui

# サービスファイル削除
rm /etc/systemd/system/rsyncgui.service
systemctl daemon-reload

# インストールディレクトリ削除(設定・ログも含む)
rm -rf /opt/rsyncgui

# crontab に登録したスケジュールを削除
crontab -l | grep -v '# rsyncgui-managed' | crontab -
タイトルとURLをコピーしました