diff --git a/README.md b/README.md index 7c2935c..ce988fb 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,31 @@ tmux new -s zed-hours -d 'python3 tools/time_tracking/zed_tracker.py --project-r --- +## Automatic sync (systemd user timer) + +`sync_all.py` syncs every configured project in one run. Configuration lives +in `~/.config/zed-hours-sync/`: + +| File | Purpose | +|---|---| +| `env` | `ZED_HOURS_SERVER_URL`, `ZED_HOURS_SERVER_TOKEN`, `ZED_HOURS_DEVICE_ID` (keep mode 600) | +| `projects.txt` | One project per line: `PATH [SLUG]`; slug defaults to the directory name | + +A systemd user pair drives it: + +- `zed-hours-sync.service` (oneshot; runs `sync_all.py` with the env file) +- `zed-hours-sync.timer` (every 15 minutes, `Persistent=true` to catch up after sleep) + +Useful commands: + +```sh +systemctl --user start zed-hours-sync.service # run a sync now +systemctl --user list-timers zed-hours-sync.timer # next scheduled run +journalctl --user -u zed-hours-sync.service -f # live logs +``` + +--- + ## Generating a report ```bash diff --git a/sync_all.py b/sync_all.py new file mode 100755 index 0000000..6ee4afb --- /dev/null +++ b/sync_all.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Sync every configured Zed Hours project to the server in one run. + +Reads a project list from a simple config file (one project per line: +`PATH [SLUG]`), validates the shared ZED_HOURS_* environment once, and +invokes sync.py for each project that has a `.zed-hours/` directory. + +Designed for a systemd user timer: per-project failures are reported but do +not stop the remaining projects; the run exits nonzero if any sync failed. +""" + +import argparse +import os +import subprocess +import sys +from pathlib import Path + +DEFAULT_CONFIG = Path.home() / ".config" / "zed-hours-sync" / "projects.txt" +REQUIRED_SETTINGS = ("ZED_HOURS_SERVER_URL", "ZED_HOURS_SERVER_TOKEN", "ZED_HOURS_DEVICE_ID") + + +def parse_config(config_path: Path) -> list[tuple[Path, str | None]]: + """Return (project_root, slug_or_None) pairs from the config file. + + Paths with spaces are not supported; keep the `PATH [SLUG]` format simple. + """ + projects: list[tuple[Path, str | None]] = [] + for lineno, raw in enumerate(config_path.read_text(encoding="utf-8").splitlines(), 1): + line = raw.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) == 1: + root, slug = parts[0], None + elif len(parts) == 2: + root, slug = parts + else: + raise ValueError(f"{config_path}:{lineno}: expected 'PATH [SLUG]', got: {line}") + root_path = Path(os.path.expanduser(root)) + projects.append((root_path, slug)) + return projects + + +def main() -> int: + parser = argparse.ArgumentParser(description="Sync all configured Zed Hours projects.") + parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG, help="Project list file (default: %(default)s)") + args = parser.parse_args() + + missing = [name for name in REQUIRED_SETTINGS if not os.environ.get(name, "").strip()] + if missing: + print(f"Missing environment variables: {', '.join(missing)}", file=sys.stderr) + return 2 + + if not args.config.exists(): + print(f"Config file not found: {args.config}", file=sys.stderr) + return 2 + + try: + projects = parse_config(args.config) + except (OSError, ValueError) as exc: + print(f"Invalid config: {exc}", file=sys.stderr) + return 2 + + if not projects: + print(f"No projects configured in {args.config}.") + return 0 + + sync_script = Path(__file__).resolve().parent / "sync.py" + failed: list[str] = [] + + for root, slug in projects: + if not (root / ".zed-hours").is_dir(): + print(f"Skipping {root}: no .zed-hours directory") + continue + command = [sys.executable, str(sync_script), "--project-root", str(root)] + if slug: + command += ["--project-slug", slug] + print(f"--- Syncing {root}" + (f" as '{slug}'" if slug else "") + " ---", flush=True) + result = subprocess.run(command, check=False) + if result.returncode != 0: + failed.append(str(root)) + + if failed: + print(f"Sync finished with failures: {', '.join(failed)}", file=sys.stderr) + return 1 + print("Sync finished OK.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file