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.
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clockify-export = clockify_export.cli:main
@@ -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.