From 74d2bfd3a7cf727740c66de69fba84da7fb04642 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Mon, 14 Sep 2026 13:14:12 +0200 Subject: [PATCH] Add opt-in server sync --- README.md | 34 +++++++++++++++++ time_track.py | 100 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55a8633..6538134 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A small, dependency-free Python time tracker for Waybar with a start/stop button - **Live elapsed time** shown in the bar while running. - **Persistent JSON storage** in `~/.local/share/time_track/data.json` (override with `TIME_TRACK_DIR`). - **Timesheet generation** in Markdown, grouped by task and by day. +- **Optional server sync** of completed entries, using environment-only configuration. ## Files @@ -62,8 +63,41 @@ If the Waybar module appears but has no text, check that `"format"` is **not emp # Generate a Markdown timesheet ./time_track.py timesheet week ./time_track.py timesheet month + +# Sync completed entries (after setting the environment variables below) +./time_track.py sync ``` +## Optional server sync + +Sync is disabled unless all configuration is provided through the process environment. No server URL, token, or device identifier is written to a configuration file. + +```sh +export TIME_TRACK_SERVER_URL="https://time-track.example.com" +export TIME_TRACK_SERVER_TOKEN="your-api-token" +export TIME_TRACK_DEVICE_ID="laptop-2026" +./time_track.py sync +``` + +`sync` sends a `POST` request to `${TIME_TRACK_SERVER_URL}/api/v1/sync/time-track` with `Authorization: Bearer ` and a 15-second timeout. Its JSON request body is: + +```json +{ + "device_id": "laptop-2026", + "entries": [ + { + "id": "6cf5c9f0-7f65-4fcd-a76f-02cc2a9f3ca3", + "task": "client-a", + "start": "2026-09-14T09:00:00", + "end": "2026-09-14T10:00:00", + "duration": 3600 + } + ] +} +``` + +Only finished entries are sent. Each completed entry receives a stable UUID. On first load after upgrading, historic entries are assigned UUIDs and the local data file is updated, so repeating a sync submits the same entry IDs for server-side idempotency. The active session is never uploaded. + ## Waybar behavior - **Left click**: start or stop a session. diff --git a/time_track.py b/time_track.py index d872419..2310a12 100755 --- a/time_track.py +++ b/time_track.py @@ -8,6 +8,7 @@ Usage: time_track.py timesheet [week|month] -- generate a Markdown timesheet time_track.py current -- show the active session, if any time_track.py stop -- stop the active session + time_track.py sync -- upload finished sessions to a server """ import argparse @@ -16,14 +17,19 @@ import os import shutil import subprocess import sys +import uuid from datetime import datetime, timedelta from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import urlsplit +from urllib.request import Request, urlopen DATA_DIR = Path( os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track") ) DATA_FILE = DATA_DIR / "data.json" DEFAULT_TASK = "" +SYNC_TIMEOUT_SECONDS = 15 def load_data(): @@ -31,7 +37,22 @@ def load_data(): if DATA_FILE.exists(): try: with DATA_FILE.open("r", encoding="utf-8") as f: - return json.load(f) + data = json.load(f) + migrated = False + entry_ids = set() + for entry in data.get("entries", []): + try: + entry_id = str(uuid.UUID(entry.get("id", ""))) + except (AttributeError, TypeError, ValueError): + entry_id = "" + if not entry_id or entry_id in entry_ids: + entry_id = str(uuid.uuid4()) + entry["id"] = entry_id + migrated = True + entry_ids.add(entry_id) + if migrated: + save_data(data) + return data except (json.JSONDecodeError, OSError): pass return {"active": False, "current": None, "entries": []} @@ -162,6 +183,7 @@ def toggle(task=None): end = datetime.now() duration = int((end - start).total_seconds()) entry = { + "id": str(uuid.uuid4()), "task": data["current"].get("task", ""), "start": data["current"]["start"], "end": end.isoformat(timespec="seconds"), @@ -242,6 +264,77 @@ def status(): print(json.dumps(waybar)) +def sync(): + """Upload all finished entries using environment-only configuration.""" + server_url = os.environ.get("TIME_TRACK_SERVER_URL", "").rstrip("/") + token = os.environ.get("TIME_TRACK_SERVER_TOKEN", "") + device_id = os.environ.get("TIME_TRACK_DEVICE_ID", "") + missing = [ + name + for name, value in ( + ("TIME_TRACK_SERVER_URL", server_url), + ("TIME_TRACK_SERVER_TOKEN", token), + ("TIME_TRACK_DEVICE_ID", device_id), + ) + if not value + ] + if missing: + print( + "Sync is not configured. Set " + ", ".join(missing) + ".", + file=sys.stderr, + ) + return 1 + + parsed_url = urlsplit(server_url) + if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc: + print( + "Sync is not configured: TIME_TRACK_SERVER_URL must be an absolute HTTP(S) URL.", + file=sys.stderr, + ) + return 1 + + data = load_data() + entries = [ + { + "id": entry["id"], + "task": entry.get("task", ""), + "start": entry["start"], + "end": entry["end"], + "duration": entry["duration"], + } + for entry in data["entries"] + ] + payload = json.dumps({"device_id": device_id, "entries": entries}).encode("utf-8") + request = Request( + f"{server_url}/api/v1/sync/time-track", + data=payload, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + method="POST", + ) + + try: + with urlopen(request, timeout=SYNC_TIMEOUT_SECONDS) as response: + status = response.status + response.read() + except HTTPError as error: + print(f"Sync failed: server returned HTTP {error.code}.", file=sys.stderr) + return 1 + except URLError as error: + reason = error.reason + print(f"Sync failed: could not reach server ({reason}).", file=sys.stderr) + return 1 + except (OSError, ValueError) as error: + print(f"Sync failed: request error ({error}).", file=sys.stderr) + return 1 + + print(f"Synced {len(entries)} finished entr{'y' if len(entries) == 1 else 'ies'} (HTTP {status}).") + return 0 + + def current(): data = load_data() if data["active"]: @@ -358,6 +451,7 @@ def main(): subparsers.add_parser("status", help="emit Waybar JSON") subparsers.add_parser("current", help="show the active session") subparsers.add_parser("stop", help="stop the active session") + subparsers.add_parser("sync", help="upload finished sessions to the configured server") p_log = subparsers.add_parser("log", help="show recent entries") p_log.add_argument("count", nargs="?", type=int, default=10) @@ -381,6 +475,8 @@ def main(): current() elif args.command == "stop": stop() + elif args.command == "sync": + return sync() elif args.command == "log": log_cmd(args.count) elif args.command == "timesheet": @@ -388,4 +484,4 @@ def main(): if __name__ == "__main__": - main() + sys.exit(main() or 0)