Add opt-in server sync

This commit is contained in:
2026-09-14 13:14:12 +02:00
parent 2b33045e3f
commit 74d2bfd3a7
2 changed files with 132 additions and 2 deletions
+34
View File
@@ -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 <token>` 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.
+98 -2
View File
@@ -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)