Add opt-in server sync
This commit is contained in:
+98
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user