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.
Files changed (31) hide show
  1. clockify_export-0.3.1/.vscode/settings.json +5 -0
  2. {clockify_export-0.2.0 → clockify_export-0.3.1}/CHANGELOG.md +17 -0
  3. {clockify_export-0.2.0 → clockify_export-0.3.1}/PKG-INFO +108 -18
  4. {clockify_export-0.2.0 → clockify_export-0.3.1}/PLAN.md +6 -0
  5. {clockify_export-0.2.0 → clockify_export-0.3.1}/README.md +107 -17
  6. {clockify_export-0.2.0 → clockify_export-0.3.1}/mise.toml +1 -1
  7. {clockify_export-0.2.0 → clockify_export-0.3.1}/pyproject.toml +5 -1
  8. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/cli.py +50 -7
  9. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/config.py +27 -4
  10. clockify_export-0.3.1/src/clockify_export/systemd/clockify-export.timer +10 -0
  11. clockify_export-0.3.1/src/clockify_export/systemd_install.py +55 -0
  12. clockify_export-0.3.1/systemd/clockify-export.service +8 -0
  13. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_cli.py +41 -4
  14. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_config.py +20 -0
  15. {clockify_export-0.2.0 → clockify_export-0.3.1}/uv.lock +2 -2
  16. {clockify_export-0.2.0 → clockify_export-0.3.1}/.env.example +0 -0
  17. {clockify_export-0.2.0 → clockify_export-0.3.1}/.github/workflows/publish.yml +0 -0
  18. {clockify_export-0.2.0 → clockify_export-0.3.1}/.gitignore +0 -0
  19. {clockify_export-0.2.0 → clockify_export-0.3.1}/AGENTS.md +0 -0
  20. {clockify_export-0.2.0 → clockify_export-0.3.1}/LICENSE +0 -0
  21. {clockify_export-0.2.0 → clockify_export-0.3.1}/mise-tasks/bump-version +0 -0
  22. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/__init__.py +0 -0
  23. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/api.py +0 -0
  24. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/cache.py +0 -0
  25. {clockify_export-0.2.0 → clockify_export-0.3.1}/src/clockify_export/export.py +0 -0
  26. {clockify_export-0.2.0 → clockify_export-0.3.1/src/clockify_export}/systemd/clockify-export.service +0 -0
  27. {clockify_export-0.2.0 → clockify_export-0.3.1}/systemd/clockify-export.timer +0 -0
  28. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/conftest.py +0 -0
  29. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_api.py +0 -0
  30. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_cache.py +0 -0
  31. {clockify_export-0.2.0 → clockify_export-0.3.1}/tests/test_export.py +0 -0
@@ -0,0 +1,5 @@
1
+ {
2
+ "chat.tools.terminal.autoApprove": {
3
+ "uv": true
4
+ }
5
+ }
@@ -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.2.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/) (or any Python 3.11+ + pip).
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
- ### Global install (recommended, adds `clockify-export` to your PATH)
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. API key
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
- # b) Environment variable
103
+ ```bash
104
+ # Environment variable
86
105
  export CLOCKIFY_API_KEY=YOUR_KEY
87
106
 
88
- # c) .env file in the project directory
107
+ # .env file in the project directory
89
108
  echo "CLOCKIFY_API_KEY=YOUR_KEY" > .env
90
109
 
91
- # d) Command line flag
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
- ## Usage
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 "Peter's workspace"
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/etalytics workspace/2026/etalytics workspace_2026-09.csv
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/) (or any Python 3.11+ + pip).
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
- ### Global install (recommended, adds `clockify-export` to your PATH)
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. API key
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
- # b) Environment variable
77
+ ```bash
78
+ # Environment variable
60
79
  export CLOCKIFY_API_KEY=YOUR_KEY
61
80
 
62
- # c) .env file in the project directory
81
+ # .env file in the project directory
63
82
  echo "CLOCKIFY_API_KEY=YOUR_KEY" > .env
64
83
 
65
- # d) Command line flag
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
- ## Usage
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 "Peter's workspace"
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/etalytics workspace/2026/etalytics workspace_2026-09.csv
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.2.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
- if not state:
121
- print("No export state found.")
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
- print("Export state:")
124
- for ws_id, info in state.items():
125
- print(f" {ws_id}: last_exported={info.get('last_exported', 'never')}")
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
- workspaces = getattr(args, "workspace", []) if args else []
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,10 @@
1
+ [Unit]
2
+ Description=Run Clockify Export daily
3
+
4
+ [Timer]
5
+ OnBootSec=15min
6
+ OnUnitActiveSec=24h
7
+ Persistent=true
8
+
9
+ [Install]
10
+ WantedBy=timers.target
@@ -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")
@@ -0,0 +1,8 @@
1
+ [Unit]
2
+ Description=Clockify Export - Daily export of time entries to CSV
3
+ After=network.target
4
+
5
+ [Service]
6
+ Type=oneshot
7
+ ExecStart=%h/.local/bin/clockify-export
8
+ WorkingDirectory=%h
@@ -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 "Export state:" in captured.out
108
- assert "ws123" in captured.out
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"
@@ -1,10 +1,10 @@
1
1
  version = 1
2
- revision = 3
2
+ revision = 5
3
3
  requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
6
6
  name = "clockify-export"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  source = { editable = "." }
9
9
 
10
10
  [package.optional-dependencies]
File without changes