clockify-export 0.2.0__tar.gz → 0.3.1__tar.gz
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-0.3.1/.vscode/settings.json +5 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/CHANGELOG.md +17 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/PKG-INFO +108 -18
- {clockify_export-0.2.0 → clockify_export-0.3.1}/PLAN.md +6 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/README.md +107 -17
- {clockify_export-0.2.0 → clockify_export-0.3.1}/mise.toml +1 -1
- {clockify_export-0.2.0 → clockify_export-0.3.1}/pyproject.toml +5 -1
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/cli.py +50 -7
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/config.py +27 -4
- clockify_export-0.3.1/src/clockify_export/systemd/clockify-export.timer +10 -0
- clockify_export-0.3.1/src/clockify_export/systemd_install.py +55 -0
- clockify_export-0.3.1/systemd/clockify-export.service +8 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_cli.py +41 -4
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_config.py +20 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/uv.lock +2 -2
- {clockify_export-0.2.0 → clockify_export-0.3.1}/.env.example +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/.github/workflows/publish.yml +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/.gitignore +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/AGENTS.md +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/LICENSE +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/mise-tasks/bump-version +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/__init__.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/api.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/cache.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/export.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1/src/clockify_export}/systemd/clockify-export.service +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/systemd/clockify-export.timer +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/conftest.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_api.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_cache.py +0 -0
- {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_export.py +0 -0
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.1]
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `--install-systemd` now uses the path of the running executable when writing the systemd service.
|
|
14
|
+
|
|
15
|
+
## [0.2.0]
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- CLI config help now documents supported config entries and example values (`--config-help`).
|
|
19
|
+
- Workspace subset exports can be configured via `workspaces = [...]` in config or repeated `-w/--workspace` flags.
|
|
20
|
+
- `clockify-export --install-systemd` and `mise install-systemd` installs the user-level systemd service and timer for daily exports.
|
|
21
|
+
- `--status` now shows workspace names with IDs and marks stale state entries that are missing from the API.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- Export state now stores each workspace name alongside the last exported timestamp so deleted or renamed workspaces remain identifiable.
|
|
25
|
+
- Updated project plan status: workspace subset workflow, config help, and user systemd installation are complete and documented.
|
|
26
|
+
|
|
10
27
|
## [0.2.0]
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clockify-export
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Export Clockify time entries to CSV with incremental support
|
|
5
5
|
Project-URL: Homepage, https://github.com/ptandler/clockify-export
|
|
6
6
|
Project-URL: Repository, https://github.com/ptandler/clockify-export
|
|
@@ -47,9 +47,19 @@ and writes one CSV per calendar month.
|
|
|
47
47
|
|
|
48
48
|
## Installation
|
|
49
49
|
|
|
50
|
-
Requires [uv](https://docs.astral.sh/uv/)
|
|
50
|
+
Requires Python 3.11+ and either [mise](https://mise.jdx.dev/) or [uv](https://docs.astral.sh/uv/) for tool installation.
|
|
51
51
|
|
|
52
|
-
###
|
|
52
|
+
### Install with mise
|
|
53
|
+
|
|
54
|
+
If you already use `mise`, it supports pipx as backend and can use uv if installed.
|
|
55
|
+
The simplest install path is:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
mise use -g uv
|
|
59
|
+
mise use -g pipx:clockify-export
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Install directly with uv
|
|
53
63
|
|
|
54
64
|
```bash
|
|
55
65
|
uv tool install clockify-export # from PyPI
|
|
@@ -67,33 +77,40 @@ uv sync
|
|
|
67
77
|
|
|
68
78
|
## Setup
|
|
69
79
|
|
|
70
|
-
### 1.
|
|
80
|
+
### 1. Create and edit the config file
|
|
71
81
|
|
|
72
82
|
Generate an API key in Clockify: **Profile → Preferences → API → Manage API Keys → Generate**.
|
|
73
|
-
|
|
74
|
-
Provide it via one of (highest priority first):
|
|
83
|
+
Then create the config file and set your key and any defaults you want to use:
|
|
75
84
|
|
|
76
85
|
```bash
|
|
77
|
-
# a) Config file (recommended)
|
|
78
86
|
clockify-export --init-config
|
|
79
87
|
# then edit ~/.config/clockify-export/config.toml:
|
|
80
88
|
#
|
|
81
|
-
# # Clockify export configuration
|
|
82
89
|
# api_key = "YOUR_KEY"
|
|
83
90
|
# export_dir = "export"
|
|
91
|
+
# refetch = 1
|
|
92
|
+
# workspaces = ["Primary Workspace", "Client Projects"]
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
You can also print the supported keys anytime:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
clockify-export --config-help
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Alternatively, provide the key via one of the other supported sources (highest priority first):
|
|
84
102
|
|
|
85
|
-
|
|
103
|
+
```bash
|
|
104
|
+
# Environment variable
|
|
86
105
|
export CLOCKIFY_API_KEY=YOUR_KEY
|
|
87
106
|
|
|
88
|
-
#
|
|
107
|
+
# .env file in the project directory
|
|
89
108
|
echo "CLOCKIFY_API_KEY=YOUR_KEY" > .env
|
|
90
109
|
|
|
91
|
-
#
|
|
110
|
+
# Command line flag
|
|
92
111
|
clockify-export --api-key YOUR_KEY
|
|
93
112
|
```
|
|
94
113
|
|
|
95
|
-
> Note: TOML is parsed with Python's stdlib `tomllib` — no extra dependency.
|
|
96
|
-
|
|
97
114
|
### 2. Export directory
|
|
98
115
|
|
|
99
116
|
Defaults to `./export`. Override via config file or `-o` flag:
|
|
@@ -102,7 +119,72 @@ Defaults to `./export`. Override via config file or `-o` flag:
|
|
|
102
119
|
clockify-export -o /data/clockify
|
|
103
120
|
```
|
|
104
121
|
|
|
105
|
-
|
|
122
|
+
### 3. Check the setup and workspace status
|
|
123
|
+
|
|
124
|
+
Before enabling the daily timer, confirm that the config and workspace discovery look correct:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
clockify-export --status
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This shows the known workspaces and any saved export state, which helps catch missing or renamed workspaces early.
|
|
131
|
+
|
|
132
|
+
### 4. Install daily systemd timer
|
|
133
|
+
|
|
134
|
+
If you installed via PyPI, you can install the user-level systemd service and timer without needing the repo checkout:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
clockify-export --install-systemd
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
This copies the bundled service/timer into `~/.config/systemd/user/` and enables the timer.
|
|
141
|
+
The installed service writes the currently resolved `clockify-export` executable path, so installs in a different directory still work correctly.
|
|
142
|
+
|
|
143
|
+
See below for other options.
|
|
144
|
+
|
|
145
|
+
## Command-line options
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
clockify-export --help
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The CLI supports the following options:
|
|
152
|
+
|
|
153
|
+
- `-o, --output-dir` — output directory for CSV files; defaults to `./export` or the config value
|
|
154
|
+
- `--api-key` — override the Clockify API key for this run
|
|
155
|
+
- `--full` — export everything from the beginning and ignore incremental state
|
|
156
|
+
- `--from YYYY-MM[,YYYY-MM-DD]` — export from a specific month/day; always starts at the beginning of the month
|
|
157
|
+
- `--last N` — export the last N complete months
|
|
158
|
+
- `--refetch N` — re-fetch N months back from the last exported timestamp
|
|
159
|
+
- `-w, --workspace NAME` — export only matching workspace names; repeat to select multiple workspaces
|
|
160
|
+
- `--status` — show workspace information plus stored export state; includes missing workspaces by ID if the API no longer returns them
|
|
161
|
+
- `--config-help` — print supported config keys and example values
|
|
162
|
+
- `--init-config` — create a default config file in `~/.config/clockify-export/config.toml`
|
|
163
|
+
- `--install-systemd` — install the user-level systemd service and timer for daily exports
|
|
164
|
+
- `--no-cache` — skip cached projects/clients/tags and fetch fresh data
|
|
165
|
+
- `--version` — print the installed version
|
|
166
|
+
|
|
167
|
+
## Config file
|
|
168
|
+
|
|
169
|
+
The config file is read from `~/.config/clockify-export/config.toml`.
|
|
170
|
+
|
|
171
|
+
Supported keys:
|
|
172
|
+
|
|
173
|
+
```toml
|
|
174
|
+
api_key = "YOUR_KEY"
|
|
175
|
+
export_dir = "export"
|
|
176
|
+
refetch = 1
|
|
177
|
+
workspaces = ["Primary Workspace", "Client Projects"]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Priority order is:
|
|
181
|
+
|
|
182
|
+
1. CLI flags
|
|
183
|
+
2. environment variables (`CLOCKIFY_API_KEY`, `CLOCKIFY_EXPORT_DIR`)
|
|
184
|
+
3. config file
|
|
185
|
+
4. defaults
|
|
186
|
+
|
|
187
|
+
### Example usage
|
|
106
188
|
|
|
107
189
|
```bash
|
|
108
190
|
# Full export of all workspaces (everything since the beginning)
|
|
@@ -111,13 +193,17 @@ clockify-export --full
|
|
|
111
193
|
# Incremental export (default — only fetches entries after last run)
|
|
112
194
|
clockify-export
|
|
113
195
|
|
|
114
|
-
# Export only specific workspace(s)
|
|
115
|
-
clockify-export -w "
|
|
196
|
+
# Export only specific workspace(s) by name
|
|
197
|
+
clockify-export -w "Primary Workspace"
|
|
198
|
+
clockify-export -w "Primary Workspace" -w "Client Projects"
|
|
199
|
+
|
|
200
|
+
# Or in config.toml:
|
|
201
|
+
# workspaces = ["Primary Workspace", "Client Projects"]
|
|
116
202
|
|
|
117
203
|
# Export since a specific date (ignores incremental state)
|
|
118
204
|
clockify-export --from 2026-01-01
|
|
119
205
|
|
|
120
|
-
# Show export state
|
|
206
|
+
# Show workspace status and saved export state
|
|
121
207
|
clockify-export --status
|
|
122
208
|
```
|
|
123
209
|
|
|
@@ -134,7 +220,7 @@ Each filename carries the workspace and year, so files stay identifiable even
|
|
|
134
220
|
if you copy or move them out of the directory tree:
|
|
135
221
|
|
|
136
222
|
```
|
|
137
|
-
export/
|
|
223
|
+
export/Example Workspace/2026/Example Workspace_2026-09.csv
|
|
138
224
|
```
|
|
139
225
|
|
|
140
226
|
### CSV columns
|
|
@@ -246,3 +332,7 @@ systemctl --user disable --now clockify-export.timer
|
|
|
246
332
|
|
|
247
333
|
> Note: The service uses `Type=oneshot` so it completes and exits each day.
|
|
248
334
|
> Logs can be viewed with `journalctl --user -u clockify-export.service`.
|
|
335
|
+
|
|
336
|
+
## Thanks
|
|
337
|
+
|
|
338
|
+
This whole project was generated via opencode and I tried several free models. 🙏
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
|
|
20
20
|
- [ ] Auto-refresh cache when unknown project/client/tag ID encountered during entry processing
|
|
21
21
|
|
|
22
|
+
## Next
|
|
23
|
+
|
|
24
|
+
- [x] show config help, i.e. supported config entries
|
|
25
|
+
- [x] add command to install systemd unit & timer as user
|
|
26
|
+
- [x] Workspace subset workflow: use workspace names in config and CLI, and `--status` now lists available workspaces alongside saved state, including missing workspaces by ID.
|
|
27
|
+
|
|
22
28
|
---
|
|
23
29
|
|
|
24
30
|
## 📋 Backlog
|
|
@@ -21,9 +21,19 @@ and writes one CSV per calendar month.
|
|
|
21
21
|
|
|
22
22
|
## Installation
|
|
23
23
|
|
|
24
|
-
Requires [uv](https://docs.astral.sh/uv/)
|
|
24
|
+
Requires Python 3.11+ and either [mise](https://mise.jdx.dev/) or [uv](https://docs.astral.sh/uv/) for tool installation.
|
|
25
25
|
|
|
26
|
-
###
|
|
26
|
+
### Install with mise
|
|
27
|
+
|
|
28
|
+
If you already use `mise`, it supports pipx as backend and can use uv if installed.
|
|
29
|
+
The simplest install path is:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
mise use -g uv
|
|
33
|
+
mise use -g pipx:clockify-export
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Install directly with uv
|
|
27
37
|
|
|
28
38
|
```bash
|
|
29
39
|
uv tool install clockify-export # from PyPI
|
|
@@ -41,33 +51,40 @@ uv sync
|
|
|
41
51
|
|
|
42
52
|
## Setup
|
|
43
53
|
|
|
44
|
-
### 1.
|
|
54
|
+
### 1. Create and edit the config file
|
|
45
55
|
|
|
46
56
|
Generate an API key in Clockify: **Profile → Preferences → API → Manage API Keys → Generate**.
|
|
47
|
-
|
|
48
|
-
Provide it via one of (highest priority first):
|
|
57
|
+
Then create the config file and set your key and any defaults you want to use:
|
|
49
58
|
|
|
50
59
|
```bash
|
|
51
|
-
# a) Config file (recommended)
|
|
52
60
|
clockify-export --init-config
|
|
53
61
|
# then edit ~/.config/clockify-export/config.toml:
|
|
54
62
|
#
|
|
55
|
-
# # Clockify export configuration
|
|
56
63
|
# api_key = "YOUR_KEY"
|
|
57
64
|
# export_dir = "export"
|
|
65
|
+
# refetch = 1
|
|
66
|
+
# workspaces = ["Primary Workspace", "Client Projects"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
You can also print the supported keys anytime:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
clockify-export --config-help
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Alternatively, provide the key via one of the other supported sources (highest priority first):
|
|
58
76
|
|
|
59
|
-
|
|
77
|
+
```bash
|
|
78
|
+
# Environment variable
|
|
60
79
|
export CLOCKIFY_API_KEY=YOUR_KEY
|
|
61
80
|
|
|
62
|
-
#
|
|
81
|
+
# .env file in the project directory
|
|
63
82
|
echo "CLOCKIFY_API_KEY=YOUR_KEY" > .env
|
|
64
83
|
|
|
65
|
-
#
|
|
84
|
+
# Command line flag
|
|
66
85
|
clockify-export --api-key YOUR_KEY
|
|
67
86
|
```
|
|
68
87
|
|
|
69
|
-
> Note: TOML is parsed with Python's stdlib `tomllib` — no extra dependency.
|
|
70
|
-
|
|
71
88
|
### 2. Export directory
|
|
72
89
|
|
|
73
90
|
Defaults to `./export`. Override via config file or `-o` flag:
|
|
@@ -76,7 +93,72 @@ Defaults to `./export`. Override via config file or `-o` flag:
|
|
|
76
93
|
clockify-export -o /data/clockify
|
|
77
94
|
```
|
|
78
95
|
|
|
79
|
-
|
|
96
|
+
### 3. Check the setup and workspace status
|
|
97
|
+
|
|
98
|
+
Before enabling the daily timer, confirm that the config and workspace discovery look correct:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
clockify-export --status
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
This shows the known workspaces and any saved export state, which helps catch missing or renamed workspaces early.
|
|
105
|
+
|
|
106
|
+
### 4. Install daily systemd timer
|
|
107
|
+
|
|
108
|
+
If you installed via PyPI, you can install the user-level systemd service and timer without needing the repo checkout:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
clockify-export --install-systemd
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
This copies the bundled service/timer into `~/.config/systemd/user/` and enables the timer.
|
|
115
|
+
The installed service writes the currently resolved `clockify-export` executable path, so installs in a different directory still work correctly.
|
|
116
|
+
|
|
117
|
+
See below for other options.
|
|
118
|
+
|
|
119
|
+
## Command-line options
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
clockify-export --help
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The CLI supports the following options:
|
|
126
|
+
|
|
127
|
+
- `-o, --output-dir` — output directory for CSV files; defaults to `./export` or the config value
|
|
128
|
+
- `--api-key` — override the Clockify API key for this run
|
|
129
|
+
- `--full` — export everything from the beginning and ignore incremental state
|
|
130
|
+
- `--from YYYY-MM[,YYYY-MM-DD]` — export from a specific month/day; always starts at the beginning of the month
|
|
131
|
+
- `--last N` — export the last N complete months
|
|
132
|
+
- `--refetch N` — re-fetch N months back from the last exported timestamp
|
|
133
|
+
- `-w, --workspace NAME` — export only matching workspace names; repeat to select multiple workspaces
|
|
134
|
+
- `--status` — show workspace information plus stored export state; includes missing workspaces by ID if the API no longer returns them
|
|
135
|
+
- `--config-help` — print supported config keys and example values
|
|
136
|
+
- `--init-config` — create a default config file in `~/.config/clockify-export/config.toml`
|
|
137
|
+
- `--install-systemd` — install the user-level systemd service and timer for daily exports
|
|
138
|
+
- `--no-cache` — skip cached projects/clients/tags and fetch fresh data
|
|
139
|
+
- `--version` — print the installed version
|
|
140
|
+
|
|
141
|
+
## Config file
|
|
142
|
+
|
|
143
|
+
The config file is read from `~/.config/clockify-export/config.toml`.
|
|
144
|
+
|
|
145
|
+
Supported keys:
|
|
146
|
+
|
|
147
|
+
```toml
|
|
148
|
+
api_key = "YOUR_KEY"
|
|
149
|
+
export_dir = "export"
|
|
150
|
+
refetch = 1
|
|
151
|
+
workspaces = ["Primary Workspace", "Client Projects"]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Priority order is:
|
|
155
|
+
|
|
156
|
+
1. CLI flags
|
|
157
|
+
2. environment variables (`CLOCKIFY_API_KEY`, `CLOCKIFY_EXPORT_DIR`)
|
|
158
|
+
3. config file
|
|
159
|
+
4. defaults
|
|
160
|
+
|
|
161
|
+
### Example usage
|
|
80
162
|
|
|
81
163
|
```bash
|
|
82
164
|
# Full export of all workspaces (everything since the beginning)
|
|
@@ -85,13 +167,17 @@ clockify-export --full
|
|
|
85
167
|
# Incremental export (default — only fetches entries after last run)
|
|
86
168
|
clockify-export
|
|
87
169
|
|
|
88
|
-
# Export only specific workspace(s)
|
|
89
|
-
clockify-export -w "
|
|
170
|
+
# Export only specific workspace(s) by name
|
|
171
|
+
clockify-export -w "Primary Workspace"
|
|
172
|
+
clockify-export -w "Primary Workspace" -w "Client Projects"
|
|
173
|
+
|
|
174
|
+
# Or in config.toml:
|
|
175
|
+
# workspaces = ["Primary Workspace", "Client Projects"]
|
|
90
176
|
|
|
91
177
|
# Export since a specific date (ignores incremental state)
|
|
92
178
|
clockify-export --from 2026-01-01
|
|
93
179
|
|
|
94
|
-
# Show export state
|
|
180
|
+
# Show workspace status and saved export state
|
|
95
181
|
clockify-export --status
|
|
96
182
|
```
|
|
97
183
|
|
|
@@ -108,7 +194,7 @@ Each filename carries the workspace and year, so files stay identifiable even
|
|
|
108
194
|
if you copy or move them out of the directory tree:
|
|
109
195
|
|
|
110
196
|
```
|
|
111
|
-
export/
|
|
197
|
+
export/Example Workspace/2026/Example Workspace_2026-09.csv
|
|
112
198
|
```
|
|
113
199
|
|
|
114
200
|
### CSV columns
|
|
@@ -220,3 +306,7 @@ systemctl --user disable --now clockify-export.timer
|
|
|
220
306
|
|
|
221
307
|
> Note: The service uses `Type=oneshot` so it completes and exits each day.
|
|
222
308
|
> Logs can be viewed with `journalctl --user -u clockify-export.service`.
|
|
309
|
+
|
|
310
|
+
## Thanks
|
|
311
|
+
|
|
312
|
+
This whole project was generated via opencode and I tried several free models. 🙏
|
|
@@ -12,4 +12,4 @@ export-full = "uv run clockify-export --full --output-dir ./export"
|
|
|
12
12
|
status = "uv run clockify-export --status"
|
|
13
13
|
init-config = "uv run clockify-export --init-config"
|
|
14
14
|
test = "uv run python -c \"from clockify_export.api import ClockifyAPI; print('import ok')\""
|
|
15
|
-
install-systemd = "cp systemd/clockify-export.service \"$HOME/.config/systemd/user/\" && cp systemd/clockify-export.timer \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now clockify-export.timer"
|
|
15
|
+
install-systemd = "cp systemd/clockify-export/clockify-export.service \"$HOME/.config/systemd/user/\" && cp systemd/clockify-export.timer \"$HOME/.config/systemd/user/\" && systemctl --user daemon-reload && systemctl --user enable --now clockify-export.timer"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "clockify-export"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.1"
|
|
4
4
|
description = "Export Clockify time entries to CSV with incremental support"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -40,6 +40,10 @@ build-backend = "hatchling.build"
|
|
|
40
40
|
|
|
41
41
|
[tool.hatch.build.targets.wheel]
|
|
42
42
|
packages = ["src/clockify_export"]
|
|
43
|
+
include = [
|
|
44
|
+
"src/clockify_export/systemd/*.service",
|
|
45
|
+
"src/clockify_export/systemd/*.timer",
|
|
46
|
+
]
|
|
43
47
|
|
|
44
48
|
[tool.uv]
|
|
45
49
|
package = true
|
|
@@ -9,6 +9,7 @@ from datetime import datetime, timedelta, timezone
|
|
|
9
9
|
from .api import ClockifyAPI, ClockifyError
|
|
10
10
|
from .config import CONFIG_FILE, Config, CONFIG_HELP, create_config_file, get_last_exported, set_last_exported, parse_from_date, calculate_last_months, calculate_refetch_date
|
|
11
11
|
from .export import export_workspace
|
|
12
|
+
from .systemd_install import install_systemd_user_units
|
|
12
13
|
from . import __version__
|
|
13
14
|
|
|
14
15
|
|
|
@@ -74,11 +75,21 @@ def main() -> None:
|
|
|
74
75
|
action="store_true",
|
|
75
76
|
help="Show export state and exit",
|
|
76
77
|
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--config-help",
|
|
80
|
+
action="store_true",
|
|
81
|
+
help="Print supported configuration entries and exit",
|
|
82
|
+
)
|
|
77
83
|
parser.add_argument(
|
|
78
84
|
"--init-config",
|
|
79
85
|
action="store_true",
|
|
80
86
|
help="Create ~/.config/clockify-export/config.toml and exit",
|
|
81
87
|
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--install-systemd",
|
|
90
|
+
action="store_true",
|
|
91
|
+
help="Install the user-level systemd service and timer for daily exports",
|
|
92
|
+
)
|
|
82
93
|
parser.add_argument(
|
|
83
94
|
"--no-cache",
|
|
84
95
|
action="store_true",
|
|
@@ -86,12 +97,20 @@ def main() -> None:
|
|
|
86
97
|
)
|
|
87
98
|
args = parser.parse_args()
|
|
88
99
|
|
|
100
|
+
if args.config_help:
|
|
101
|
+
print(CONFIG_HELP)
|
|
102
|
+
raise SystemExit(0)
|
|
103
|
+
|
|
89
104
|
if args.init_config:
|
|
90
105
|
path = create_config_file()
|
|
91
106
|
print(f"Created config file: {path}")
|
|
92
107
|
print("Edit it to set your API key and export directory.")
|
|
93
108
|
return
|
|
94
109
|
|
|
110
|
+
if args.install_systemd:
|
|
111
|
+
install_systemd_user_units()
|
|
112
|
+
return
|
|
113
|
+
|
|
95
114
|
# Load configuration
|
|
96
115
|
cfg = Config.load(args)
|
|
97
116
|
|
|
@@ -112,17 +131,41 @@ def main() -> None:
|
|
|
112
131
|
|
|
113
132
|
# Status mode
|
|
114
133
|
if args.status:
|
|
115
|
-
import json
|
|
116
134
|
from .config import get_state
|
|
117
135
|
state = get_state()
|
|
118
136
|
print(f"Export directory: {cfg.export_dir}")
|
|
119
137
|
print(f"Config file: {CONFIG_FILE}")
|
|
120
|
-
|
|
121
|
-
|
|
138
|
+
try:
|
|
139
|
+
workspaces = api.get_workspaces()
|
|
140
|
+
except ClockifyError as e:
|
|
141
|
+
print(f"Error fetching workspaces: {e}", file=sys.stderr)
|
|
142
|
+
workspaces = []
|
|
143
|
+
|
|
144
|
+
available_by_id = {str(ws.get("id")): ws for ws in workspaces if ws.get("id")}
|
|
145
|
+
workspace_ids = sorted(set(available_by_id) | set(state), key=lambda ws_id: (
|
|
146
|
+
(available_by_id.get(ws_id, {}).get("name") or state.get(ws_id, {}).get("name") or "").lower(),
|
|
147
|
+
ws_id,
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
print("Workspace status:")
|
|
151
|
+
if not workspace_ids:
|
|
152
|
+
print(" No workspaces found.")
|
|
122
153
|
return
|
|
123
|
-
|
|
124
|
-
for ws_id
|
|
125
|
-
|
|
154
|
+
|
|
155
|
+
for ws_id in workspace_ids:
|
|
156
|
+
info = state.get(ws_id, {})
|
|
157
|
+
api_ws = available_by_id.get(ws_id, {})
|
|
158
|
+
name = api_ws.get("name") or info.get("name") or "(unknown workspace)"
|
|
159
|
+
if ws_id in available_by_id:
|
|
160
|
+
status = ""
|
|
161
|
+
else:
|
|
162
|
+
status = " - missing from API"
|
|
163
|
+
last_exported = info.get("last_exported")
|
|
164
|
+
if last_exported:
|
|
165
|
+
detail = f" - last_exported={last_exported}"
|
|
166
|
+
else:
|
|
167
|
+
detail = " - no export state"
|
|
168
|
+
print(f" {name} ({ws_id}){status}{detail}")
|
|
126
169
|
return
|
|
127
170
|
|
|
128
171
|
# Discover workspaces
|
|
@@ -193,7 +236,7 @@ def main() -> None:
|
|
|
193
236
|
if counts:
|
|
194
237
|
# Update state to the actual latest entry date (not end of month)
|
|
195
238
|
if latest_entry_dt:
|
|
196
|
-
set_last_exported(ws_id, latest_entry_dt.isoformat())
|
|
239
|
+
set_last_exported(ws_id, latest_entry_dt.isoformat(), ws_name)
|
|
197
240
|
total = sum(counts.values())
|
|
198
241
|
print(f" Total: {total} entries across {len(counts)} month(s)")
|
|
199
242
|
else:
|
|
@@ -26,6 +26,7 @@ DEFAULTS = {
|
|
|
26
26
|
"api_key": None,
|
|
27
27
|
"export_dir": "export",
|
|
28
28
|
"refetch": 1,
|
|
29
|
+
"workspaces": [],
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
CONFIG_HELP = """
|
|
@@ -33,12 +34,14 @@ Supported configuration options (config.toml):
|
|
|
33
34
|
api_key - Clockify API key (required)
|
|
34
35
|
export_dir - Output directory for CSV files (default: "export")
|
|
35
36
|
refetch - Number of months to re-fetch from last exported date (default: 1)
|
|
37
|
+
workspaces - Optional list of workspace names to export (e.g. ["Workspace One", "Workspace Two"])
|
|
36
38
|
|
|
37
39
|
Example config.toml:
|
|
38
40
|
# Clockify export configuration
|
|
39
41
|
api_key = "YOUR_API_KEY"
|
|
40
42
|
export_dir = "export"
|
|
41
43
|
refetch = 1
|
|
44
|
+
workspaces = ["Workspace One", "Workspace Two"]
|
|
42
45
|
"""
|
|
43
46
|
|
|
44
47
|
|
|
@@ -74,9 +77,20 @@ class Config:
|
|
|
74
77
|
)
|
|
75
78
|
|
|
76
79
|
full = getattr(args, "full", False) if args else False
|
|
77
|
-
|
|
80
|
+
cli_workspaces = getattr(args, "workspace", None) if args else None
|
|
81
|
+
if cli_workspaces is None:
|
|
82
|
+
cli_workspaces = []
|
|
83
|
+
if isinstance(cli_workspaces, str):
|
|
84
|
+
workspaces = [cli_workspaces]
|
|
85
|
+
else:
|
|
86
|
+
workspaces = list(cli_workspaces)
|
|
87
|
+
if not workspaces:
|
|
88
|
+
file_workspaces = file_cfg.get("workspaces", DEFAULTS["workspaces"])
|
|
89
|
+
if isinstance(file_workspaces, str):
|
|
90
|
+
file_workspaces = [file_workspaces]
|
|
91
|
+
workspaces = list(file_workspaces)
|
|
78
92
|
from_date = getattr(args, "from_date", None) if args else None
|
|
79
|
-
|
|
93
|
+
|
|
80
94
|
# For refetch: CLI arg wins, then config file, then default
|
|
81
95
|
if args and getattr(args, "refetch", None) is not None:
|
|
82
96
|
refetch = args.refetch
|
|
@@ -135,6 +149,8 @@ def create_config_file(api_key: str = "", export_dir: str = "export") -> Path:
|
|
|
135
149
|
f'export_dir = "{export_dir}"\n'
|
|
136
150
|
"# Number of months to re-fetch from last exported date (default: 1)\n"
|
|
137
151
|
"refetch = 1\n"
|
|
152
|
+
"# Optional: export only specific workspace names\n"
|
|
153
|
+
"# workspaces = [\"Workspace One\", \"Workspace Two\"]\n"
|
|
138
154
|
)
|
|
139
155
|
return CONFIG_FILE
|
|
140
156
|
|
|
@@ -194,9 +210,16 @@ def get_last_exported(workspace_id: str) -> str | None:
|
|
|
194
210
|
return state.get(workspace_id, {}).get("last_exported")
|
|
195
211
|
|
|
196
212
|
|
|
197
|
-
def set_last_exported(workspace_id: str, timestamp: str) -> None:
|
|
213
|
+
def set_last_exported(workspace_id: str, timestamp: str, workspace_name: str | None = None) -> None:
|
|
198
214
|
state = get_state()
|
|
199
215
|
ws = state.setdefault(workspace_id, {})
|
|
200
216
|
ws["last_exported"] = timestamp
|
|
217
|
+
if workspace_name is not None:
|
|
218
|
+
ws["name"] = workspace_name
|
|
201
219
|
ws["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
202
|
-
save_state(state)
|
|
220
|
+
save_state(state)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def get_workspace_name(workspace_id: str) -> str | None:
|
|
224
|
+
state = get_state()
|
|
225
|
+
return state.get(workspace_id, {}).get("name")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Helpers for installing the daily systemd timer for clockify-export."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _resolve_clockify_export_binary() -> str:
|
|
12
|
+
"""Return the executable path for the installed clockify-export command."""
|
|
13
|
+
executable = shutil.which("clockify-export")
|
|
14
|
+
if executable:
|
|
15
|
+
return executable
|
|
16
|
+
|
|
17
|
+
# If the tool was installed in a managed environment without a PATH entry
|
|
18
|
+
# available to systemd, try common pipx locations as a fallback.
|
|
19
|
+
for candidate in (
|
|
20
|
+
Path.home() / ".local" / "bin" / "clockify-export",
|
|
21
|
+
Path.home() / ".local" / "share" / "mise" / "installs" / "pipx-clockify-export" / "latest" / "bin" / "clockify-export",
|
|
22
|
+
Path.home() / ".local" / "share" / "pipx" / "venvs" / "clockify-export" / "bin" / "clockify-export",
|
|
23
|
+
Path.home() / ".local" / "share" / "uv" / "tools" / "clockify-export" / "bin" / "clockify-export",
|
|
24
|
+
):
|
|
25
|
+
if candidate.exists():
|
|
26
|
+
return str(candidate)
|
|
27
|
+
|
|
28
|
+
return "clockify-export"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def install_systemd_user_units() -> None:
|
|
32
|
+
"""Install the user systemd timer and service for daily exports."""
|
|
33
|
+
target_dir = Path.home() / ".config" / "systemd" / "user"
|
|
34
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
bundled_dir = Path(__file__).resolve().parent / "systemd"
|
|
37
|
+
service_source = (bundled_dir / "clockify-export.service").read_text(encoding="utf-8")
|
|
38
|
+
service_source = service_source.replace("ExecStart=%h/.local/bin/clockify-export", f"ExecStart={_resolve_clockify_export_binary()}")
|
|
39
|
+
(target_dir / "clockify-export.service").write_text(service_source, encoding="utf-8")
|
|
40
|
+
(target_dir / "clockify-export.timer").write_text(
|
|
41
|
+
(bundled_dir / "clockify-export.timer").read_text(encoding="utf-8"),
|
|
42
|
+
encoding="utf-8",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for command in (
|
|
46
|
+
["systemctl", "--user", "daemon-reload"],
|
|
47
|
+
["systemctl", "--user", "enable", "--now", "clockify-export.timer"],
|
|
48
|
+
):
|
|
49
|
+
try:
|
|
50
|
+
subprocess.run(command, check=True, capture_output=True, text=True)
|
|
51
|
+
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
|
52
|
+
print(f"Warning: unable to run {' '.join(command)}: {exc}", file=sys.stderr)
|
|
53
|
+
|
|
54
|
+
print(f"Installed systemd user units in {target_dir}")
|
|
55
|
+
print("Use: systemctl --user status clockify-export.timer")
|
|
@@ -64,6 +64,38 @@ class TestCLI:
|
|
|
64
64
|
assert "api_key" in content
|
|
65
65
|
assert "export_dir" in content
|
|
66
66
|
|
|
67
|
+
def test_config_help_flag(self, capsys):
|
|
68
|
+
with pytest.raises(SystemExit) as exc:
|
|
69
|
+
run_main(["--config-help"])
|
|
70
|
+
assert exc.value.code == 0
|
|
71
|
+
captured = capsys.readouterr()
|
|
72
|
+
assert "api_key" in captured.out
|
|
73
|
+
assert "refetch" in captured.out
|
|
74
|
+
|
|
75
|
+
def test_install_systemd_flag(self, tmp_path):
|
|
76
|
+
with isolated_env(tmp_path):
|
|
77
|
+
expected_dir = tmp_path / ".config" / "systemd" / "user"
|
|
78
|
+
with patch("clockify_export.systemd_install.subprocess.run") as mock_run:
|
|
79
|
+
run_main(["--install-systemd"])
|
|
80
|
+
assert mock_run.call_count >= 2
|
|
81
|
+
assert any(call.args[0][:3] == ["systemctl", "--user", "daemon-reload"] for call in mock_run.call_args_list)
|
|
82
|
+
installed_files = list(expected_dir.glob("*"))
|
|
83
|
+
assert any(f.name == "clockify-export.service" for f in installed_files)
|
|
84
|
+
assert any(f.name == "clockify-export.timer" for f in installed_files)
|
|
85
|
+
|
|
86
|
+
def test_install_systemd_uses_resolved_cli_path(self, tmp_path):
|
|
87
|
+
with isolated_env(tmp_path):
|
|
88
|
+
expected_dir = tmp_path / ".config" / "systemd" / "user"
|
|
89
|
+
resolved = tmp_path / "custom-bin" / "clockify-export"
|
|
90
|
+
resolved.parent.mkdir(parents=True)
|
|
91
|
+
with patch("clockify_export.systemd_install.shutil.which", return_value=str(resolved)):
|
|
92
|
+
with patch("clockify_export.systemd_install.subprocess.run"):
|
|
93
|
+
run_main(["--install-systemd"])
|
|
94
|
+
|
|
95
|
+
service_file = expected_dir / "clockify-export.service"
|
|
96
|
+
content = service_file.read_text(encoding="utf-8")
|
|
97
|
+
assert f"ExecStart={resolved}" in content
|
|
98
|
+
|
|
67
99
|
def test_missing_api_key_exits(self, capsys, tmp_path):
|
|
68
100
|
with isolated_env(tmp_path) as config_dir:
|
|
69
101
|
with patch("clockify_export.config.CONFIG_DIR", config_dir):
|
|
@@ -92,20 +124,25 @@ class TestCLI:
|
|
|
92
124
|
def test_status_flag_shows_state(self, capsys, tmp_path, sample_state):
|
|
93
125
|
with isolated_env(tmp_path) as config_dir:
|
|
94
126
|
(config_dir / "config.toml").write_text('api_key = "test_key"\nexport_dir = "export"\n')
|
|
95
|
-
(config_dir / "state.json").write_text('{"ws123": {"last_exported": "2026-01-15T17:00:00+00:00"}}')
|
|
127
|
+
(config_dir / "state.json").write_text('{"ws123": {"last_exported": "2026-01-15T17:00:00+00:00", "name": "Old Workspace"}}')
|
|
96
128
|
with patch("clockify_export.config.CONFIG_DIR", config_dir):
|
|
97
129
|
with patch("clockify_export.config.CONFIG_FILE", config_dir / "config.toml"):
|
|
98
130
|
with patch("clockify_export.config.STATE_FILE", config_dir / "state.json"):
|
|
99
131
|
with patch("clockify_export.cli.ClockifyAPI") as mock_api_class:
|
|
100
132
|
mock_api = MagicMock()
|
|
101
|
-
mock_api.get_workspaces.return_value = [{"id": "ws1", "name": "WS"}]
|
|
133
|
+
mock_api.get_workspaces.return_value = [{"id": "ws1", "name": "WS"}, {"id": "ws2", "name": "Other Workspace"}]
|
|
102
134
|
mock_api_class.return_value = mock_api
|
|
103
135
|
|
|
104
136
|
run_main(["--status"])
|
|
105
137
|
captured = capsys.readouterr()
|
|
106
138
|
assert "Export directory:" in captured.out
|
|
107
|
-
assert "
|
|
108
|
-
assert "
|
|
139
|
+
assert "Workspace status:" in captured.out
|
|
140
|
+
assert "WS (ws1)" in captured.out
|
|
141
|
+
assert "Other Workspace (ws2)" in captured.out
|
|
142
|
+
assert "Old Workspace (ws123)" in captured.out
|
|
143
|
+
assert "available" not in captured.out.lower()
|
|
144
|
+
assert "missing from API" in captured.out
|
|
145
|
+
assert "last_exported" in captured.out
|
|
109
146
|
|
|
110
147
|
def test_workspace_filtering(self, tmp_path):
|
|
111
148
|
with isolated_env(tmp_path) as config_dir:
|
|
@@ -11,7 +11,9 @@ from clockify_export.config import (
|
|
|
11
11
|
Config,
|
|
12
12
|
calculate_last_months,
|
|
13
13
|
calculate_refetch_date,
|
|
14
|
+
get_state,
|
|
14
15
|
parse_from_date,
|
|
16
|
+
set_last_exported,
|
|
15
17
|
)
|
|
16
18
|
|
|
17
19
|
|
|
@@ -172,6 +174,15 @@ class TestConfigLoad:
|
|
|
172
174
|
cfg = Config.load(args)
|
|
173
175
|
assert cfg.workspaces == ["WS1", "WS2"]
|
|
174
176
|
|
|
177
|
+
def test_config_file_workspaces(self, monkeypatch, temp_config_dir):
|
|
178
|
+
import clockify_export.config as config_module
|
|
179
|
+
config_module.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
config_module.CONFIG_FILE.write_text('api_key = "file_key"\nworkspaces = ["WS1", "WS2"]\n')
|
|
181
|
+
monkeypatch.delenv("CLOCKIFY_API_KEY", raising=False)
|
|
182
|
+
args = type("Args", (), {"api_key": None, "output_dir": None, "full": False, "workspace": None, "from_date": None, "refetch": None})()
|
|
183
|
+
cfg = Config.load(args)
|
|
184
|
+
assert cfg.workspaces == ["WS1", "WS2"]
|
|
185
|
+
|
|
175
186
|
def test_cli_full(self, monkeypatch, temp_config_dir):
|
|
176
187
|
monkeypatch.setenv("CLOCKIFY_API_KEY", "test_key")
|
|
177
188
|
args = type("Args", (), {"api_key": None, "output_dir": None, "full": True, "workspace": [], "from_date": None, "refetch": None})()
|
|
@@ -190,6 +201,15 @@ class TestConfigLoad:
|
|
|
190
201
|
cfg = Config.load(args)
|
|
191
202
|
assert cfg.refetch == 5
|
|
192
203
|
|
|
204
|
+
def test_set_last_exported_keeps_workspace_name(self, monkeypatch, tmp_path):
|
|
205
|
+
import clockify_export.config as config_module
|
|
206
|
+
config_module.CONFIG_DIR = tmp_path / ".config" / "clockify-export"
|
|
207
|
+
config_module.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
config_module.STATE_FILE = config_module.CONFIG_DIR / "state.json"
|
|
209
|
+
set_last_exported("ws123", "2026-01-15T17:00:00+00:00", "Old Workspace")
|
|
210
|
+
state = get_state()
|
|
211
|
+
assert state["ws123"]["name"] == "Old Workspace"
|
|
212
|
+
|
|
193
213
|
def test_dotenv_fallback(self, monkeypatch, tmp_path):
|
|
194
214
|
import clockify_export.config as config_module
|
|
195
215
|
env_file = tmp_path / ".env"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clockify_export-0.2.0 → clockify_export-0.3.1/src/clockify_export}/systemd/clockify-export.service
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|