Add opt-in server sync
This commit is contained in:
@@ -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.
|
- **Live elapsed time** shown in the bar while running.
|
||||||
- **Persistent JSON storage** in `~/.local/share/time_track/data.json` (override with `TIME_TRACK_DIR`).
|
- **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.
|
- **Timesheet generation** in Markdown, grouped by task and by day.
|
||||||
|
- **Optional server sync** of completed entries, using environment-only configuration.
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
@@ -62,8 +63,41 @@ If the Waybar module appears but has no text, check that `"format"` is **not emp
|
|||||||
# Generate a Markdown timesheet
|
# Generate a Markdown timesheet
|
||||||
./time_track.py timesheet week
|
./time_track.py timesheet week
|
||||||
./time_track.py timesheet month
|
./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
|
## Waybar behavior
|
||||||
|
|
||||||
- **Left click**: start or stop a session.
|
- **Left click**: start or stop a session.
|
||||||
|
|||||||
+98
-2
@@ -8,6 +8,7 @@ Usage:
|
|||||||
time_track.py timesheet [week|month] -- generate a Markdown timesheet
|
time_track.py timesheet [week|month] -- generate a Markdown timesheet
|
||||||
time_track.py current -- show the active session, if any
|
time_track.py current -- show the active session, if any
|
||||||
time_track.py stop -- stop the active session
|
time_track.py stop -- stop the active session
|
||||||
|
time_track.py sync -- upload finished sessions to a server
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -16,14 +17,19 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.error import HTTPError, URLError
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
DATA_DIR = Path(
|
DATA_DIR = Path(
|
||||||
os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track")
|
os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track")
|
||||||
)
|
)
|
||||||
DATA_FILE = DATA_DIR / "data.json"
|
DATA_FILE = DATA_DIR / "data.json"
|
||||||
DEFAULT_TASK = ""
|
DEFAULT_TASK = ""
|
||||||
|
SYNC_TIMEOUT_SECONDS = 15
|
||||||
|
|
||||||
|
|
||||||
def load_data():
|
def load_data():
|
||||||
@@ -31,7 +37,22 @@ def load_data():
|
|||||||
if DATA_FILE.exists():
|
if DATA_FILE.exists():
|
||||||
try:
|
try:
|
||||||
with DATA_FILE.open("r", encoding="utf-8") as f:
|
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):
|
except (json.JSONDecodeError, OSError):
|
||||||
pass
|
pass
|
||||||
return {"active": False, "current": None, "entries": []}
|
return {"active": False, "current": None, "entries": []}
|
||||||
@@ -162,6 +183,7 @@ def toggle(task=None):
|
|||||||
end = datetime.now()
|
end = datetime.now()
|
||||||
duration = int((end - start).total_seconds())
|
duration = int((end - start).total_seconds())
|
||||||
entry = {
|
entry = {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
"task": data["current"].get("task", ""),
|
"task": data["current"].get("task", ""),
|
||||||
"start": data["current"]["start"],
|
"start": data["current"]["start"],
|
||||||
"end": end.isoformat(timespec="seconds"),
|
"end": end.isoformat(timespec="seconds"),
|
||||||
@@ -242,6 +264,77 @@ def status():
|
|||||||
print(json.dumps(waybar))
|
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():
|
def current():
|
||||||
data = load_data()
|
data = load_data()
|
||||||
if data["active"]:
|
if data["active"]:
|
||||||
@@ -358,6 +451,7 @@ def main():
|
|||||||
subparsers.add_parser("status", help="emit Waybar JSON")
|
subparsers.add_parser("status", help="emit Waybar JSON")
|
||||||
subparsers.add_parser("current", help="show the active session")
|
subparsers.add_parser("current", help="show the active session")
|
||||||
subparsers.add_parser("stop", help="stop 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 = subparsers.add_parser("log", help="show recent entries")
|
||||||
p_log.add_argument("count", nargs="?", type=int, default=10)
|
p_log.add_argument("count", nargs="?", type=int, default=10)
|
||||||
@@ -381,6 +475,8 @@ def main():
|
|||||||
current()
|
current()
|
||||||
elif args.command == "stop":
|
elif args.command == "stop":
|
||||||
stop()
|
stop()
|
||||||
|
elif args.command == "sync":
|
||||||
|
return sync()
|
||||||
elif args.command == "log":
|
elif args.command == "log":
|
||||||
log_cmd(args.count)
|
log_cmd(args.count)
|
||||||
elif args.command == "timesheet":
|
elif args.command == "timesheet":
|
||||||
@@ -388,4 +484,4 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
sys.exit(main() or 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user