clockify-export 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- clockify_export/__init__.py +0 -0
- clockify_export/api.py +187 -0
- clockify_export/cli.py +171 -0
- clockify_export/config.py +142 -0
- clockify_export/export.py +156 -0
- clockify_export-0.1.1.dist-info/METADATA +193 -0
- clockify_export-0.1.1.dist-info/RECORD +10 -0
- clockify_export-0.1.1.dist-info/WHEEL +4 -0
- clockify_export-0.1.1.dist-info/entry_points.txt +2 -0
- clockify_export-0.1.1.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
clockify_export/api.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Minimal Clockify API client using only stdlib."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
BASE_URL = "https://api.clockify.me/api/v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ClockifyError(Exception):
|
|
16
|
+
def __init__(self, status: int, message: str):
|
|
17
|
+
self.status = status
|
|
18
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ClockifyAPI:
|
|
22
|
+
"""Minimal Clockify REST API client."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, api_key: str, base_url: str = BASE_URL):
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
self.base_url = base_url.rstrip("/")
|
|
27
|
+
|
|
28
|
+
def _request(self, method: str, path: str, body: dict | None = None) -> tuple[Any, dict[str, str]]:
|
|
29
|
+
"""Make a request, return (parsed_json, headers_dict)."""
|
|
30
|
+
url = f"{self.base_url}{path}"
|
|
31
|
+
data = json.dumps(body).encode() if body else None
|
|
32
|
+
req = urllib.request.Request(
|
|
33
|
+
url,
|
|
34
|
+
data=data,
|
|
35
|
+
method=method,
|
|
36
|
+
headers={
|
|
37
|
+
"X-Api-Key": self.api_key,
|
|
38
|
+
"Content-Type": "application/json",
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(req) as resp:
|
|
43
|
+
headers = dict(resp.headers)
|
|
44
|
+
raw = resp.read()
|
|
45
|
+
return json.loads(raw) if raw else None, headers
|
|
46
|
+
except urllib.error.HTTPError as e:
|
|
47
|
+
body_text = e.read().decode(errors="replace")
|
|
48
|
+
raise ClockifyError(e.code, body_text) from e
|
|
49
|
+
|
|
50
|
+
def _get(self, path: str) -> tuple[Any, dict[str, str]]:
|
|
51
|
+
return self._request("GET", path)
|
|
52
|
+
|
|
53
|
+
# ── Workspaces ──
|
|
54
|
+
|
|
55
|
+
def get_workspaces(self) -> list[dict]:
|
|
56
|
+
data, _ = self._get("/workspaces")
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
# ── Current user ──
|
|
60
|
+
|
|
61
|
+
def get_current_user(self) -> dict:
|
|
62
|
+
data, _ = self._get("/user")
|
|
63
|
+
return data
|
|
64
|
+
|
|
65
|
+
# ── Projects ──
|
|
66
|
+
|
|
67
|
+
def get_projects(self, workspace_id: str) -> list[dict]:
|
|
68
|
+
"""Fetch all projects (paginated)."""
|
|
69
|
+
projects: list[dict] = []
|
|
70
|
+
page = 1
|
|
71
|
+
while True:
|
|
72
|
+
data, headers = self._get(
|
|
73
|
+
f"/workspaces/{workspace_id}/projects?page={page}&page-size=50"
|
|
74
|
+
)
|
|
75
|
+
if not data:
|
|
76
|
+
break
|
|
77
|
+
projects.extend(data)
|
|
78
|
+
if headers.get("Last-Page", "false").lower() == "true":
|
|
79
|
+
break
|
|
80
|
+
page += 1
|
|
81
|
+
return projects
|
|
82
|
+
|
|
83
|
+
# ── Tasks ──
|
|
84
|
+
|
|
85
|
+
def get_tasks(self, workspace_id: str, project_id: str) -> list[dict]:
|
|
86
|
+
"""Fetch all tasks for a project (paginated)."""
|
|
87
|
+
tasks: list[dict] = []
|
|
88
|
+
page = 1
|
|
89
|
+
while True:
|
|
90
|
+
data, headers = self._get(
|
|
91
|
+
f"/workspaces/{workspace_id}/projects/{project_id}/tasks?page={page}&page-size=50"
|
|
92
|
+
)
|
|
93
|
+
if not data:
|
|
94
|
+
break
|
|
95
|
+
tasks.extend(data)
|
|
96
|
+
if headers.get("Last-Page", "false").lower() == "true":
|
|
97
|
+
break
|
|
98
|
+
page += 1
|
|
99
|
+
return tasks
|
|
100
|
+
|
|
101
|
+
# ── Clients ──
|
|
102
|
+
|
|
103
|
+
def get_clients(self, workspace_id: str) -> list[dict]:
|
|
104
|
+
"""Fetch all clients (paginated)."""
|
|
105
|
+
clients: list[dict] = []
|
|
106
|
+
page = 1
|
|
107
|
+
while True:
|
|
108
|
+
data, headers = self._get(
|
|
109
|
+
f"/workspaces/{workspace_id}/clients?page={page}&page-size=50"
|
|
110
|
+
)
|
|
111
|
+
if not data:
|
|
112
|
+
break
|
|
113
|
+
clients.extend(data)
|
|
114
|
+
if headers.get("Last-Page", "false").lower() == "true":
|
|
115
|
+
break
|
|
116
|
+
page += 1
|
|
117
|
+
return clients
|
|
118
|
+
|
|
119
|
+
# ── Tags ──
|
|
120
|
+
|
|
121
|
+
def get_tags(self, workspace_id: str) -> list[dict]:
|
|
122
|
+
"""Fetch all tags (paginated)."""
|
|
123
|
+
tags: list[dict] = []
|
|
124
|
+
page = 1
|
|
125
|
+
while True:
|
|
126
|
+
data, headers = self._get(
|
|
127
|
+
f"/workspaces/{workspace_id}/tags?page={page}&page-size=50"
|
|
128
|
+
)
|
|
129
|
+
if not data:
|
|
130
|
+
break
|
|
131
|
+
tags.extend(data)
|
|
132
|
+
if headers.get("Last-Page", "false").lower() == "true":
|
|
133
|
+
break
|
|
134
|
+
page += 1
|
|
135
|
+
return tags
|
|
136
|
+
|
|
137
|
+
# ── Time entries ──
|
|
138
|
+
|
|
139
|
+
def get_time_entries(
|
|
140
|
+
self,
|
|
141
|
+
workspace_id: str,
|
|
142
|
+
user_id: str,
|
|
143
|
+
start: datetime | None = None,
|
|
144
|
+
end: datetime | None = None,
|
|
145
|
+
) -> list[dict]:
|
|
146
|
+
"""Fetch all time entries for a user, paginated."""
|
|
147
|
+
entries: list[dict] = []
|
|
148
|
+
page = 1
|
|
149
|
+
while True:
|
|
150
|
+
params = f"page={page}&page-size=200"
|
|
151
|
+
if start:
|
|
152
|
+
# Clockify expects "yyyy-MM-ddThh:mm:ssZ" format
|
|
153
|
+
utc = start.astimezone(timezone.utc)
|
|
154
|
+
params += f"&start={utc.strftime('%Y-%m-%dT%H:%M:%SZ')}"
|
|
155
|
+
if end:
|
|
156
|
+
utc = end.astimezone(timezone.utc)
|
|
157
|
+
params += f"&end={utc.strftime('%Y-%m-%dT%H:%M:%SZ')}"
|
|
158
|
+
data, headers = self._get(
|
|
159
|
+
f"/workspaces/{workspace_id}/user/{user_id}/time-entries?{params}"
|
|
160
|
+
)
|
|
161
|
+
if not data:
|
|
162
|
+
break
|
|
163
|
+
entries.extend(data)
|
|
164
|
+
if headers.get("Last-Page", "false").lower() == "true":
|
|
165
|
+
break
|
|
166
|
+
page += 1
|
|
167
|
+
if page % 10 == 0:
|
|
168
|
+
print(f" ... fetched {len(entries)} entries so far (page {page})")
|
|
169
|
+
return entries
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Duration parsing ──
|
|
173
|
+
|
|
174
|
+
_DURATION_RE = re.compile(r"P(?:(\d+)D)?T?(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def parse_iso_duration_to_hours(duration_str: str) -> float:
|
|
178
|
+
"""Parse ISO 8601 duration (e.g. 'PT1H30M') to decimal hours."""
|
|
179
|
+
m = _DURATION_RE.match(duration_str)
|
|
180
|
+
if not m:
|
|
181
|
+
return 0.0
|
|
182
|
+
days = int(m.group(1) or 0)
|
|
183
|
+
hours = int(m.group(2) or 0)
|
|
184
|
+
minutes = int(m.group(3) or 0)
|
|
185
|
+
seconds = int(m.group(4) or 0)
|
|
186
|
+
total_minutes = days * 24 * 60 + hours * 60 + minutes + seconds / 60
|
|
187
|
+
return round(total_minutes / 60, 2)
|
clockify_export/cli.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""CLI entry point for clockify-export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from .api import ClockifyAPI, ClockifyError
|
|
10
|
+
from .config import CONFIG_FILE, Config, create_config_file, get_last_exported, set_last_exported
|
|
11
|
+
from .export import export_workspace
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
prog="clockify-export",
|
|
17
|
+
description="Export Clockify time entries to CSV",
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--output-dir", "-o",
|
|
21
|
+
type=str,
|
|
22
|
+
default=None,
|
|
23
|
+
help="Output directory for CSV files (default: ./export or config export_dir)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--api-key",
|
|
27
|
+
type=str,
|
|
28
|
+
default=None,
|
|
29
|
+
help="Clockify API key (overrides config/env). Alternatively use CLOCKIFY_API_KEY or config.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--full",
|
|
33
|
+
action="store_true",
|
|
34
|
+
help="Full export: ignore incremental state, export everything",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument(
|
|
37
|
+
"--from",
|
|
38
|
+
dest="from_date",
|
|
39
|
+
type=str,
|
|
40
|
+
default=None,
|
|
41
|
+
help="Export entries from this date (YYYY-MM-DD). Overrides incremental state.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--workspace",
|
|
45
|
+
"-w",
|
|
46
|
+
action="append",
|
|
47
|
+
help="Export only this workspace (can be repeated). Default: all workspaces.",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--status",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Show export state and exit",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--init-config",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Create ~/.config/clockify-export/config.toml and exit",
|
|
58
|
+
)
|
|
59
|
+
args = parser.parse_args()
|
|
60
|
+
|
|
61
|
+
if args.init_config:
|
|
62
|
+
path = create_config_file()
|
|
63
|
+
print(f"Created config file: {path}")
|
|
64
|
+
print("Edit it to set your API key and export directory.")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Load configuration
|
|
68
|
+
cfg = Config.load(args)
|
|
69
|
+
|
|
70
|
+
if not cfg.api_key:
|
|
71
|
+
print(
|
|
72
|
+
"Error: No API key found.\n"
|
|
73
|
+
" Set it via:\n"
|
|
74
|
+
" 1. --api-key flag\n"
|
|
75
|
+
" 2. CLOCKIFY_API_KEY environment variable\n"
|
|
76
|
+
" 3. ~/.config/clockify-export/config.toml\n"
|
|
77
|
+
" 4. .env file in the current directory\n"
|
|
78
|
+
" Run: clockify-export --init-config to create a config file.",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
api = ClockifyAPI(cfg.api_key)
|
|
84
|
+
|
|
85
|
+
# Status mode
|
|
86
|
+
if args.status:
|
|
87
|
+
import json
|
|
88
|
+
from .config import get_state
|
|
89
|
+
state = get_state()
|
|
90
|
+
print(f"Export directory: {cfg.export_dir}")
|
|
91
|
+
print(f"Config file: {CONFIG_FILE}")
|
|
92
|
+
if not state:
|
|
93
|
+
print("No export state found.")
|
|
94
|
+
return
|
|
95
|
+
print("Export state:")
|
|
96
|
+
for ws_id, info in state.items():
|
|
97
|
+
print(f" {ws_id}: last_exported={info.get('last_exported', 'never')}")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Discover workspaces
|
|
101
|
+
try:
|
|
102
|
+
workspaces = api.get_workspaces()
|
|
103
|
+
except ClockifyError as e:
|
|
104
|
+
print(f"Error fetching workspaces: {e}", file=sys.stderr)
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
|
|
107
|
+
if not workspaces:
|
|
108
|
+
print("No workspaces found.", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
# Filter workspaces if specified
|
|
112
|
+
if cfg.workspaces:
|
|
113
|
+
ws_names = set(cfg.workspaces)
|
|
114
|
+
workspaces = [w for w in workspaces if w.get("name") in ws_names]
|
|
115
|
+
if not workspaces:
|
|
116
|
+
print(
|
|
117
|
+
f"Workspace(s) not found. Available: {[w['name'] for w in api.get_workspaces()]}",
|
|
118
|
+
file=sys.stderr,
|
|
119
|
+
)
|
|
120
|
+
sys.exit(1)
|
|
121
|
+
|
|
122
|
+
print(f"Found {len(workspaces)} workspace(s): {', '.join(w['name'] for w in workspaces)}")
|
|
123
|
+
print()
|
|
124
|
+
|
|
125
|
+
output_dir = cfg.export_dir
|
|
126
|
+
for ws in workspaces:
|
|
127
|
+
ws_id = ws["id"]
|
|
128
|
+
ws_name = ws["name"]
|
|
129
|
+
print(f"── {ws_name} ({ws_id}) ──")
|
|
130
|
+
|
|
131
|
+
# Determine start date
|
|
132
|
+
since = None
|
|
133
|
+
if cfg.from_date:
|
|
134
|
+
# Explicit --from flag overrides everything
|
|
135
|
+
since = datetime.strptime(cfg.from_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
136
|
+
print(f" --from: fetching since {cfg.from_date}")
|
|
137
|
+
elif not cfg.full:
|
|
138
|
+
last = get_last_exported(ws_id)
|
|
139
|
+
if last:
|
|
140
|
+
# Start from the day after last export
|
|
141
|
+
since = datetime.fromisoformat(last) + timedelta(days=1)
|
|
142
|
+
print(f" Incremental: fetching since {since.strftime('%Y-%m-%d')}")
|
|
143
|
+
else:
|
|
144
|
+
print(" No prior export found — exporting all data")
|
|
145
|
+
else:
|
|
146
|
+
print(" Full export mode")
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
counts = export_workspace(api, ws_id, ws_name, output_dir, since=since)
|
|
150
|
+
if counts:
|
|
151
|
+
# Update state to the latest entry date
|
|
152
|
+
latest_month = max(counts.keys())
|
|
153
|
+
latest_ts = datetime.strptime(latest_month, "%Y-%m").replace(tzinfo=timezone.utc)
|
|
154
|
+
# Set to end of month to mark the whole month as exported
|
|
155
|
+
from calendar import monthrange
|
|
156
|
+
_, last_day = monthrange(latest_ts.year, latest_ts.month)
|
|
157
|
+
latest_ts = latest_ts.replace(day=last_day)
|
|
158
|
+
set_last_exported(ws_id, latest_ts.isoformat())
|
|
159
|
+
total = sum(counts.values())
|
|
160
|
+
print(f" Total: {total} entries across {len(counts)} month(s)")
|
|
161
|
+
else:
|
|
162
|
+
print(" No entries found")
|
|
163
|
+
except ClockifyError as e:
|
|
164
|
+
print(f" Error: {e}", file=sys.stderr)
|
|
165
|
+
print()
|
|
166
|
+
|
|
167
|
+
print("Done.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Configuration management.
|
|
2
|
+
|
|
3
|
+
Loads config from (in priority order):
|
|
4
|
+
1. CLI flags (highest)
|
|
5
|
+
2. Environment variables (CLOCKIFY_API_KEY, CLOCKIFY_EXPORT_DIR)
|
|
6
|
+
3. ~/.config/clockify-export/config.toml
|
|
7
|
+
4. ~/.config/clockify-export/config.json (deprecated fallback)
|
|
8
|
+
5. Defaults (lowest)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import tomllib
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
CONFIG_DIR = Path.home() / ".config" / "clockify-export"
|
|
21
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
22
|
+
LEGACY_CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
23
|
+
STATE_FILE = CONFIG_DIR / "state.json"
|
|
24
|
+
|
|
25
|
+
DEFAULTS = {
|
|
26
|
+
"api_key": None,
|
|
27
|
+
"export_dir": "export",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Config:
|
|
33
|
+
api_key: str
|
|
34
|
+
export_dir: Path
|
|
35
|
+
full: bool = False
|
|
36
|
+
workspaces: list[str] = field(default_factory=list)
|
|
37
|
+
from_date: str | None = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def load(cls, args=None) -> Config:
|
|
41
|
+
"""Load config from file, env, and CLI args. CLI args win."""
|
|
42
|
+
file_cfg = _load_config_file()
|
|
43
|
+
|
|
44
|
+
# Priority: CLI > env > config file > defaults
|
|
45
|
+
api_key = (
|
|
46
|
+
getattr(args, "api_key", None)
|
|
47
|
+
or os.environ.get("CLOCKIFY_API_KEY")
|
|
48
|
+
or file_cfg.get("api_key")
|
|
49
|
+
)
|
|
50
|
+
if not api_key:
|
|
51
|
+
# Try .env file as last resort
|
|
52
|
+
api_key = _load_dotenv()
|
|
53
|
+
|
|
54
|
+
export_dir = Path(
|
|
55
|
+
getattr(args, "output_dir", None)
|
|
56
|
+
or os.environ.get("CLOCKIFY_EXPORT_DIR")
|
|
57
|
+
or file_cfg.get("export_dir")
|
|
58
|
+
or DEFAULTS["export_dir"]
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
full = getattr(args, "full", False) if args else False
|
|
62
|
+
workspaces = getattr(args, "workspace", []) if args else []
|
|
63
|
+
from_date = getattr(args, "from_date", None) if args else None
|
|
64
|
+
|
|
65
|
+
return cls(
|
|
66
|
+
api_key=api_key or "",
|
|
67
|
+
export_dir=export_dir,
|
|
68
|
+
full=full,
|
|
69
|
+
workspaces=workspaces or [],
|
|
70
|
+
from_date=from_date,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _load_config_file() -> dict:
|
|
75
|
+
"""Read config.toml (preferred) or legacy config.json."""
|
|
76
|
+
if CONFIG_FILE.exists():
|
|
77
|
+
try:
|
|
78
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
79
|
+
return tomllib.load(f)
|
|
80
|
+
except (tomllib.TOMLDecodeError, OSError):
|
|
81
|
+
pass
|
|
82
|
+
if LEGACY_CONFIG_FILE.exists():
|
|
83
|
+
try:
|
|
84
|
+
import warnings
|
|
85
|
+
warnings.warn(
|
|
86
|
+
f"{LEGACY_CONFIG_FILE} is deprecated, use {CONFIG_FILE} instead",
|
|
87
|
+
DeprecationWarning,
|
|
88
|
+
stacklevel=2,
|
|
89
|
+
)
|
|
90
|
+
return json.loads(LEGACY_CONFIG_FILE.read_text())
|
|
91
|
+
except (json.JSONDecodeError, OSError):
|
|
92
|
+
pass
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _load_dotenv() -> str | None:
|
|
97
|
+
"""Read CLOCKIFY_API_KEY from .env in current directory."""
|
|
98
|
+
env_file = Path(".env")
|
|
99
|
+
if env_file.exists():
|
|
100
|
+
for line in env_file.read_text().splitlines():
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if line.startswith("CLOCKIFY_API_KEY="):
|
|
103
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def create_config_file(api_key: str = "", export_dir: str = "export") -> Path:
|
|
108
|
+
"""Create a default TOML config file. Returns the path."""
|
|
109
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
CONFIG_FILE.write_text(
|
|
111
|
+
"# Clockify export configuration\n"
|
|
112
|
+
f'api_key = "{api_key}"\n'
|
|
113
|
+
f'export_dir = "{export_dir}"\n'
|
|
114
|
+
)
|
|
115
|
+
return CONFIG_FILE
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_state() -> dict:
|
|
119
|
+
if STATE_FILE.exists():
|
|
120
|
+
try:
|
|
121
|
+
return json.loads(STATE_FILE.read_text())
|
|
122
|
+
except (json.JSONDecodeError, OSError):
|
|
123
|
+
pass
|
|
124
|
+
return {}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def save_state(state: dict) -> None:
|
|
128
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
STATE_FILE.write_text(json.dumps(state, indent=2) + "\n")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_last_exported(workspace_id: str) -> str | None:
|
|
133
|
+
state = get_state()
|
|
134
|
+
return state.get(workspace_id, {}).get("last_exported")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def set_last_exported(workspace_id: str, timestamp: str) -> None:
|
|
138
|
+
state = get_state()
|
|
139
|
+
ws = state.setdefault(workspace_id, {})
|
|
140
|
+
ws["last_exported"] = timestamp
|
|
141
|
+
ws["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
142
|
+
save_state(state)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""CSV export logic for Clockify time entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .api import ClockifyAPI, parse_iso_duration_to_hours
|
|
11
|
+
|
|
12
|
+
CSV_HEADERS = [
|
|
13
|
+
"Date",
|
|
14
|
+
"Start",
|
|
15
|
+
"End",
|
|
16
|
+
"Duration (h)",
|
|
17
|
+
"Project",
|
|
18
|
+
"Customer",
|
|
19
|
+
"Task",
|
|
20
|
+
"Description",
|
|
21
|
+
"Tags",
|
|
22
|
+
"Billable",
|
|
23
|
+
"Hourly Rate",
|
|
24
|
+
"Currency",
|
|
25
|
+
"Type",
|
|
26
|
+
"Timezone",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fmt_date(iso_str: str | None) -> str:
|
|
31
|
+
if not iso_str:
|
|
32
|
+
return ""
|
|
33
|
+
dt = datetime.fromisoformat(iso_str)
|
|
34
|
+
return dt.strftime("%Y-%m-%d")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _fmt_time(iso_str: str | None) -> str:
|
|
38
|
+
if not iso_str:
|
|
39
|
+
return ""
|
|
40
|
+
dt = datetime.fromisoformat(iso_str)
|
|
41
|
+
return dt.strftime("%H:%M")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sanitize_dirname(name: str) -> str:
|
|
45
|
+
"""Make a string safe for use as a directory name."""
|
|
46
|
+
return re.sub(r'[<>:"/\\|?*]', "_", name).strip()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def export_workspace(
|
|
50
|
+
api: ClockifyAPI,
|
|
51
|
+
workspace_id: str,
|
|
52
|
+
workspace_name: str,
|
|
53
|
+
output_dir: Path,
|
|
54
|
+
since: datetime | None = None,
|
|
55
|
+
) -> dict[str, int]:
|
|
56
|
+
"""Export all time entries for a workspace, grouped by year/month.
|
|
57
|
+
|
|
58
|
+
Directory structure: {output_dir}/{workspace_name}/{year}/{MM}.csv
|
|
59
|
+
Returns dict of {YYYY-MM: row_count}.
|
|
60
|
+
"""
|
|
61
|
+
# Resolve current user
|
|
62
|
+
user = api.get_current_user()
|
|
63
|
+
user_id = user["id"]
|
|
64
|
+
print(f" User: {user.get('name', user_id)}")
|
|
65
|
+
|
|
66
|
+
# Fetch lookup tables
|
|
67
|
+
print(" Fetching projects...")
|
|
68
|
+
projects = {p["id"]: p for p in api.get_projects(workspace_id)}
|
|
69
|
+
print(" Fetching clients...")
|
|
70
|
+
clients = {c["id"]: c for c in api.get_clients(workspace_id)}
|
|
71
|
+
print(" Fetching tags...")
|
|
72
|
+
tags = {t["id"]: t for t in api.get_tags(workspace_id)}
|
|
73
|
+
|
|
74
|
+
# Fetch time entries first to know which projects have entries
|
|
75
|
+
print(" Fetching time entries...")
|
|
76
|
+
entries = api.get_time_entries(workspace_id, user_id, start=since)
|
|
77
|
+
print(f" Got {len(entries)} entries")
|
|
78
|
+
|
|
79
|
+
# Only fetch tasks for projects that have entries (saves API calls)
|
|
80
|
+
project_ids_in_entries = {e.get("projectId") for e in entries if e.get("projectId")}
|
|
81
|
+
print(f" Fetching tasks for {len(project_ids_in_entries)} active projects...")
|
|
82
|
+
tasks: dict[str, dict] = {}
|
|
83
|
+
for proj_id in project_ids_in_entries:
|
|
84
|
+
for t in api.get_tasks(workspace_id, proj_id):
|
|
85
|
+
tasks[t["id"]] = t
|
|
86
|
+
|
|
87
|
+
# Build project -> client mapping
|
|
88
|
+
project_client: dict[str, str] = {}
|
|
89
|
+
for p in projects.values():
|
|
90
|
+
client_id = p.get("clientId")
|
|
91
|
+
if client_id and client_id in clients:
|
|
92
|
+
project_client[p["id"]] = clients[client_id].get("name", "")
|
|
93
|
+
|
|
94
|
+
# Group by year/month
|
|
95
|
+
by_month: dict[str, list[dict]] = {}
|
|
96
|
+
for entry in entries:
|
|
97
|
+
start_str = entry.get("timeInterval", {}).get("start")
|
|
98
|
+
if not start_str:
|
|
99
|
+
continue
|
|
100
|
+
dt = datetime.fromisoformat(start_str)
|
|
101
|
+
month_key = dt.strftime("%Y-%m")
|
|
102
|
+
by_month.setdefault(month_key, []).append(entry)
|
|
103
|
+
|
|
104
|
+
# Write CSVs: output_dir / workspace_name / year / <workspace>_<year>-<month>.csv
|
|
105
|
+
safe_ws_name = _sanitize_dirname(workspace_name)
|
|
106
|
+
ws_dir = output_dir / safe_ws_name
|
|
107
|
+
counts: dict[str, int] = {}
|
|
108
|
+
for month_key, month_entries in sorted(by_month.items()):
|
|
109
|
+
year, month = month_key.split("-")
|
|
110
|
+
year_dir = ws_dir / year
|
|
111
|
+
year_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
# Filename carries workspace + year so files stay identifiable when moved
|
|
113
|
+
csv_path = year_dir / f"{safe_ws_name}_{year}-{month}.csv"
|
|
114
|
+
|
|
115
|
+
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
|
116
|
+
writer = csv.writer(f)
|
|
117
|
+
writer.writerow(CSV_HEADERS)
|
|
118
|
+
for entry in sorted(
|
|
119
|
+
month_entries,
|
|
120
|
+
key=lambda e: e.get("timeInterval", {}).get("start", ""),
|
|
121
|
+
):
|
|
122
|
+
start = entry.get("timeInterval", {}).get("start")
|
|
123
|
+
end = entry.get("timeInterval", {}).get("end")
|
|
124
|
+
duration_str = entry.get("timeInterval", {}).get("duration", "")
|
|
125
|
+
duration_h = parse_iso_duration_to_hours(duration_str) if duration_str else 0
|
|
126
|
+
|
|
127
|
+
project_id = entry.get("projectId", "")
|
|
128
|
+
task_id = entry.get("taskId", "")
|
|
129
|
+
tag_ids = entry.get("tagIds") or []
|
|
130
|
+
|
|
131
|
+
# Hourly rate is in cents
|
|
132
|
+
hourly_rate_obj = entry.get("hourlyRate") or {}
|
|
133
|
+
rate_cents = hourly_rate_obj.get("amount", 0)
|
|
134
|
+
currency = hourly_rate_obj.get("currency", "")
|
|
135
|
+
|
|
136
|
+
writer.writerow([
|
|
137
|
+
_fmt_date(start),
|
|
138
|
+
_fmt_time(start),
|
|
139
|
+
_fmt_time(end),
|
|
140
|
+
duration_h,
|
|
141
|
+
projects.get(project_id, {}).get("name", ""),
|
|
142
|
+
project_client.get(project_id, ""),
|
|
143
|
+
tasks.get(task_id, {}).get("name", ""),
|
|
144
|
+
entry.get("description", ""),
|
|
145
|
+
", ".join(tags.get(tid, {}).get("name", "") for tid in tag_ids),
|
|
146
|
+
entry.get("billable", ""),
|
|
147
|
+
f"{rate_cents / 100:.2f}" if rate_cents else "",
|
|
148
|
+
currency,
|
|
149
|
+
entry.get("type", ""),
|
|
150
|
+
(entry.get("timeInterval") or {}).get("timeZone", ""),
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
counts[month_key] = len(month_entries)
|
|
154
|
+
print(f" Wrote {csv_path.relative_to(output_dir)} ({len(month_entries)} entries)")
|
|
155
|
+
|
|
156
|
+
return counts
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: clockify-export
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Export Clockify time entries to CSV with incremental support
|
|
5
|
+
Project-URL: Homepage, https://github.com/ptandler/clockify-export
|
|
6
|
+
Project-URL: Repository, https://github.com/ptandler/clockify-export
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/ptandler/clockify-export/issues
|
|
8
|
+
Author: Peter Tandler
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: clockify,csv,export,incremental,time-tracking
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Clockify Export
|
|
25
|
+
|
|
26
|
+
Export your Clockify time entries to CSV files, even on the Free plan.
|
|
27
|
+
|
|
28
|
+
In April/May 2026, Clockify restricted the Free plan: CSV/Excel exports and
|
|
29
|
+
reports longer than 31 days now require a paid plan. The API itself, however,
|
|
30
|
+
is still available on the Free plan (30 requests/hour/workspace).
|
|
31
|
+
|
|
32
|
+
This tool uses the **time entries API** (not the reports API), so it is **not**
|
|
33
|
+
subject to the 31-day reporting window. It paginates through all your entries
|
|
34
|
+
and writes one CSV per calendar month.
|
|
35
|
+
|
|
36
|
+
## Features
|
|
37
|
+
|
|
38
|
+
- ✅ **Multi-workspace** — exports all your workspaces (or select with `-w`)
|
|
39
|
+
- ✅ **Incremental** — remembers what was exported and only fetches new entries
|
|
40
|
+
- ✅ **Monthly CSVs** — one file per calendar month, current month gets re-exported/overwritten
|
|
41
|
+
- ✅ **Per-workspace stat** — state is tracked in `~/.config/clockify-export/state.json`
|
|
42
|
+
- ✅ **Zero dependencies** — stdlib only (`urllib`, `csv`, `json`), Python ≥ 3.11
|
|
43
|
+
- ✅ **Config file** — API key + export path in `~/.config/clockify-export/config.toml`
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
Requires [uv](https://docs.astral.sh/uv/) (or any Python 3.11+ + pip).
|
|
48
|
+
|
|
49
|
+
### Global install (recommended, adds `clockify-export` to your PATH)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uv tool install clockify-export # from PyPI
|
|
53
|
+
# or from a local checkout:
|
|
54
|
+
uv tool install .
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Updates: `uv tool upgrade clockify-export`.
|
|
58
|
+
|
|
59
|
+
### Development (from a checkout)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uv sync
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Setup
|
|
66
|
+
|
|
67
|
+
### 1. API key
|
|
68
|
+
|
|
69
|
+
Generate an API key in Clockify: **Profile → Preferences → API → Manage API Keys → Generate**.
|
|
70
|
+
|
|
71
|
+
Provide it via one of (highest priority first):
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# a) Config file (recommended)
|
|
75
|
+
clockify-export --init-config
|
|
76
|
+
# then edit ~/.config/clockify-export/config.toml:
|
|
77
|
+
#
|
|
78
|
+
# # Clockify export configuration
|
|
79
|
+
# api_key = "YOUR_KEY"
|
|
80
|
+
# export_dir = "export"
|
|
81
|
+
|
|
82
|
+
# b) Environment variable
|
|
83
|
+
export CLOCKIFY_API_KEY=YOUR_KEY
|
|
84
|
+
|
|
85
|
+
# c) .env file in the project directory
|
|
86
|
+
echo "CLOCKIFY_API_KEY=YOUR_KEY" > .env
|
|
87
|
+
|
|
88
|
+
# d) Command line flag
|
|
89
|
+
clockify-export --api-key YOUR_KEY
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> Note: TOML is parsed with Python's stdlib `tomllib` — no extra dependency.
|
|
93
|
+
|
|
94
|
+
### 2. Export directory
|
|
95
|
+
|
|
96
|
+
Defaults to `./export`. Override via config file or `-o` flag:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
clockify-export -o /data/clockify
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# Full export of all workspaces (everything since the beginning)
|
|
106
|
+
clockify-export --full
|
|
107
|
+
|
|
108
|
+
# Incremental export (default — only fetches entries after last run)
|
|
109
|
+
clockify-export
|
|
110
|
+
|
|
111
|
+
# Export only specific workspace(s)
|
|
112
|
+
clockify-export -w "Peter's workspace"
|
|
113
|
+
|
|
114
|
+
# Export since a specific date (ignores incremental state)
|
|
115
|
+
clockify-export --from 2026-01-01
|
|
116
|
+
|
|
117
|
+
# Show export state
|
|
118
|
+
clockify-export --status
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Output structure
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
export/
|
|
125
|
+
└── <workspace name>/
|
|
126
|
+
└── <year>/
|
|
127
|
+
└── <workspace name>_<year>-<month>.csv
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Each filename carries the workspace and year, so files stay identifiable even
|
|
131
|
+
if you copy or move them out of the directory tree:
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
export/etalytics workspace/2026/etalytics workspace_2026-09.csv
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### CSV columns
|
|
138
|
+
|
|
139
|
+
| Column | Description |
|
|
140
|
+
|--------|-------------|
|
|
141
|
+
| `Date` | Entry date (YYYY-MM-DD) |
|
|
142
|
+
| `Start` | Start time (HH:MM, UTC) |
|
|
143
|
+
| `End` | End time (HH:MM, UTC) |
|
|
144
|
+
| `Duration (h)` | Duration in decimal hours (e.g. 1.5 = 1h 30m) |
|
|
145
|
+
| `Project` | Project name |
|
|
146
|
+
| `Customer` | Client/customer name |
|
|
147
|
+
| `Task` | Task name |
|
|
148
|
+
| `Description` | Entry description |
|
|
149
|
+
| `Tags` | Comma-separated tags |
|
|
150
|
+
| `Billable` | True/False |
|
|
151
|
+
| `Hourly Rate` | Billable rate (e.g. 13.00) |
|
|
152
|
+
| `Currency` | Currency code (e.g. EUR) |
|
|
153
|
+
| `Type` | Entry type (REGULAR, BREAK, …) |
|
|
154
|
+
| `Timezone` | Timezone of the entry (e.g. Europe/Berlin) |
|
|
155
|
+
|
|
156
|
+
## How incremental export works
|
|
157
|
+
|
|
158
|
+
The tool stores the last exported date per workspace in
|
|
159
|
+
`~/.config/clockify-export/state.json`:
|
|
160
|
+
|
|
161
|
+
```json
|
|
162
|
+
{
|
|
163
|
+
"5e9d613a265d3c117c6f3aeb": {
|
|
164
|
+
"last_exported": "2026-09-30T00:00:00+00:00",
|
|
165
|
+
"updated_at": "2026-09-10T10:20:38.530213+00:00"
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
On the next run, entries are fetched starting the day after `last_exported`.
|
|
171
|
+
Monthly CSVs are overwritten entirely, so a partially written current month is
|
|
172
|
+
always consistent.
|
|
173
|
+
|
|
174
|
+
## Free plan considerations
|
|
175
|
+
|
|
176
|
+
- **Rate limit**: 30 API requests/hour/workspace on Free plan. Each page of
|
|
177
|
+
200 entries = 1 request. A full export of thousands of entries can exhaust
|
|
178
|
+
the quota — that's why `--from` and `--full` exist, and why the tool fetches
|
|
179
|
+
tasks only for projects that actually have entries.
|
|
180
|
+
- If you hit the rate limit (HTTP 429), wait an hour and run again — the
|
|
181
|
+
incremental state means you won't re-fetch everything.
|
|
182
|
+
|
|
183
|
+
## Related projects
|
|
184
|
+
|
|
185
|
+
- [Clockify API docs](https://docs.clockify.me/)
|
|
186
|
+
- [damirarh/ClockifyExport](https://github.com/damirarh/ClockifyExport) — C# tool
|
|
187
|
+
using shared reports (paid-only on Free plan now)
|
|
188
|
+
- [apet97/clockify-ts-sdk](https://github.com/apet97/clockify-ts-sdk) — community
|
|
189
|
+
TypeScript SDK/CLI
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
clockify_export/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
clockify_export/api.py,sha256=ekVyU9Yy0WfxPFLId-E-NxU8gmpSkhRfTo1v-okCKA8,5924
|
|
3
|
+
clockify_export/cli.py,sha256=JsOxD19aOaWEoiKtUCBR5-kkgHfiI2IwNQIWa8y7bng,5645
|
|
4
|
+
clockify_export/config.py,sha256=ziQH0aFa2vB3cZP-SV0949zbHa5anoyajAdy592Sj5M,4197
|
|
5
|
+
clockify_export/export.py,sha256=tElB-rQWsWUBgfF_KaSsQvjyEWZJnW8er8X0Ld2-9VQ,5475
|
|
6
|
+
clockify_export-0.1.1.dist-info/METADATA,sha256=sb3S-Rc2jW0jnfLR0cTWZ3Sv3zVvQAW1hpK3rUrN0w8,5829
|
|
7
|
+
clockify_export-0.1.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
clockify_export-0.1.1.dist-info/entry_points.txt,sha256=-wbsj9roznlBMbnxLsAX9T7QooBQB-k3gMlsYEs34ew,61
|
|
9
|
+
clockify_export-0.1.1.dist-info/licenses/LICENSE,sha256=zs_y0Uq4pUMCDrnaOfeUq8-bPE5lCV8V66F0IbuNw78,1069
|
|
10
|
+
clockify_export-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter Tandler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|