clockify-export 0.1.1__tar.gz → 0.1.5__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.1.1 → clockify_export-0.1.5}/.gitignore +1 -0
- clockify_export-0.1.5/AGENTS.md +66 -0
- clockify_export-0.1.5/CHANGELOG.md +38 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/PKG-INFO +56 -1
- clockify_export-0.1.5/PLAN.md +48 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/README.md +52 -0
- clockify_export-0.1.5/mise-tasks/bump-version +13 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/mise.toml +2 -1
- {clockify_export-0.1.1 → clockify_export-0.1.5}/pyproject.toml +7 -1
- clockify_export-0.1.5/src/clockify_export/__init__.py +8 -0
- clockify_export-0.1.5/src/clockify_export/cache.py +90 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/cli.py +53 -15
- {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/config.py +60 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/export.py +66 -14
- clockify_export-0.1.5/systemd/clockify-export.service +8 -0
- clockify_export-0.1.5/systemd/clockify-export.timer +10 -0
- clockify_export-0.1.5/tests/conftest.py +155 -0
- clockify_export-0.1.5/tests/test_api.py +55 -0
- clockify_export-0.1.5/tests/test_cache.py +146 -0
- clockify_export-0.1.5/tests/test_cli.py +237 -0
- clockify_export-0.1.5/tests/test_config.py +201 -0
- clockify_export-0.1.5/tests/test_export.py +350 -0
- clockify_export-0.1.5/uv.lock +94 -0
- clockify_export-0.1.1/.idea/.gitignore +0 -8
- clockify_export-0.1.1/.idea/clockify-export.iml +0 -9
- clockify_export-0.1.1/.idea/misc.xml +0 -6
- clockify_export-0.1.1/.idea/modules.xml +0 -8
- clockify_export-0.1.1/.idea/vcs.xml +0 -6
- clockify_export-0.1.1/src/clockify_export/__init__.py +0 -0
- clockify_export-0.1.1/uv.lock +0 -8
- {clockify_export-0.1.1 → clockify_export-0.1.5}/.env.example +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/.github/workflows/publish.yml +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/LICENSE +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/api.py +0 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Agent Instructions
|
|
2
|
+
|
|
3
|
+
When generating documentation and comments, be as concise and brief as possible without loosing relevant information.
|
|
4
|
+
|
|
5
|
+
## Project Files
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|------|---------|
|
|
9
|
+
| `PLAN.md` | Development roadmap and task tracking |
|
|
10
|
+
| `CHANGELOG.md` | Version history (Keep a Changelog format) |
|
|
11
|
+
| `README.md` | User documentation |
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Updating the Plan
|
|
16
|
+
|
|
17
|
+
When **adding/removing/starting/resolving tasks**:
|
|
18
|
+
|
|
19
|
+
1. Edit `PLAN.md`
|
|
20
|
+
2. Use status markers:
|
|
21
|
+
- `[ ]` — Not started
|
|
22
|
+
- `🔄` — In progress
|
|
23
|
+
3. When a task is implemented, update `CHANGELOG.md` (in "Unreleased" section) and remove from `PLAN.md`
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Updating the Changelog
|
|
28
|
+
|
|
29
|
+
1. Edit `CHANGELOG.md`
|
|
30
|
+
2. Keep `[Unreleased]` section for things done since last release
|
|
31
|
+
3. Follow [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Versioning
|
|
36
|
+
|
|
37
|
+
This project uses [Semantic Versioning](https://semver.org/):
|
|
38
|
+
- **MAJOR** — Breaking changes
|
|
39
|
+
- **MINOR** — New features (backward compatible)
|
|
40
|
+
- **PATCH** — Bug fixes (backward compatible)
|
|
41
|
+
|
|
42
|
+
Current version defined in: `pyproject.toml` (also read by `src/clockify_export/__init__.py`)
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Code Locations
|
|
47
|
+
|
|
48
|
+
| Feature | File |
|
|
49
|
+
|---------|------|
|
|
50
|
+
| CLI entry point | `src/clockify_export/cli.py` |
|
|
51
|
+
| Export logic | `src/clockify_export/export.py` |
|
|
52
|
+
| API client | `src/clockify_export/api.py` |
|
|
53
|
+
| Config/state | `src/clockify_export/config.py` |
|
|
54
|
+
| Cache | `src/clockify_export/cache.py` |
|
|
55
|
+
|
|
56
|
+
## Tests
|
|
57
|
+
|
|
58
|
+
- Test files in `tests/` directory
|
|
59
|
+
- Run with: `uv run pytest tests/ -v`
|
|
60
|
+
- 90 tests covering:
|
|
61
|
+
- API: duration parsing, errors
|
|
62
|
+
- Config: date parsing, priority chain (CLI > env > config.toml > defaults), refetch calculations
|
|
63
|
+
- Cache: read/write, expiry, workspace data
|
|
64
|
+
- Export: CSV formatting, grouping, filters, current month filenames, cleanup
|
|
65
|
+
- CLI: flags, mutually exclusive options, workspace filtering
|
|
66
|
+
- Add more test cases when bugs are encountered
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Auto-refresh cache when unknown project/client/tag ID encountered during entry processing
|
|
12
|
+
- Cache for workspaces, projects, clients, tags with 1-week expiry
|
|
13
|
+
- `--no-cache` flag to bypass cache and force fresh API fetch
|
|
14
|
+
- Current month CSV includes date in filename (e.g., `...-2026-09-17.csv`)
|
|
15
|
+
- Auto-cleanup of old current-month files with different dates
|
|
16
|
+
- Mutually exclusive date range options: `--from`, `--last`, `--refetch`
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- Removed `last` option from config file (prevents inconsistent behavior)
|
|
20
|
+
- `last` is now CLI-only flag
|
|
21
|
+
- Cache now stores timestamp and expires after 1 week
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Cache validation: expired cache entries are ignored
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## [0.1.0] - 2026-09-10
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Initial release
|
|
32
|
+
- Multi-workspace export
|
|
33
|
+
- Incremental export with state tracking
|
|
34
|
+
- Monthly CSV output per workspace/year
|
|
35
|
+
- Config file support (`~/.config/clockify-export/config.toml`)
|
|
36
|
+
- `--full`, `--from`, `--last`, `--refetch`, `--workspace`, `--status` flags
|
|
37
|
+
- Zero dependencies (stdlib only)
|
|
38
|
+
- systemd timer support for scheduled exports
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: clockify-export
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
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
|
|
@@ -19,6 +19,9 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
19
19
|
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
20
|
Classifier: Topic :: Office/Business :: Scheduling
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
22
25
|
Description-Content-Type: text/markdown
|
|
23
26
|
|
|
24
27
|
# Clockify Export
|
|
@@ -191,3 +194,55 @@ always consistent.
|
|
|
191
194
|
## License
|
|
192
195
|
|
|
193
196
|
MIT
|
|
197
|
+
|
|
198
|
+
## Schedule Daily Export via systemd
|
|
199
|
+
|
|
200
|
+
Use can use systemd timers to call the export regularily.
|
|
201
|
+
|
|
202
|
+
### Install with mise
|
|
203
|
+
|
|
204
|
+
[mise](https://mise.jdx.dev) can install the systemd unit automatically:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
mise install-systemd
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
This copies the service/timer files to `~/.config/systemd/user/` and enables the timer.
|
|
211
|
+
|
|
212
|
+
### Systemd Timer (daily export)
|
|
213
|
+
|
|
214
|
+
To run the export automatically every day as a systemd user unit:
|
|
215
|
+
|
|
216
|
+
#### 1. Install the service and timer
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
cp systemd/clockify-export.service "$HOME/.config/systemd/user/"
|
|
220
|
+
cp systemd/clockify-export.timer "$HOME/.config/systemd/user/"
|
|
221
|
+
systemctl --user daemon-reload
|
|
222
|
+
systemctl --user enable --now clockify-export.timer
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
#### 2. Set your API key
|
|
226
|
+
|
|
227
|
+
The unit reads the Clockify API key from the config file.
|
|
228
|
+
You can create one via `clockify-export --init-config` and ensure the
|
|
229
|
+
`api_key` is set in `~/.config/clockify-export/config.toml`.
|
|
230
|
+
|
|
231
|
+
#### 3. Manage the timer
|
|
232
|
+
|
|
233
|
+
The default timer is for daily export. Feel free to adjust `OnUnitActiveSec` if desired.
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
# View status
|
|
237
|
+
systemctl --user status clockify-export.timer
|
|
238
|
+
systemctl --user status clockify-export.service
|
|
239
|
+
|
|
240
|
+
# Manual run
|
|
241
|
+
systemctl --user clockify-export
|
|
242
|
+
|
|
243
|
+
# Disable
|
|
244
|
+
systemctl --user disable --now clockify-export.timer
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
> Note: The service uses `Type=oneshot` so it completes and exits each day.
|
|
248
|
+
> Logs can be viewed with `journalctl --user -u clockify-export.service`.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Development Plan
|
|
2
|
+
|
|
3
|
+
## Current Version: 0.2.0
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## ✅ Completed (v0.2.0)
|
|
8
|
+
|
|
9
|
+
- [x] Cache for workspaces, projects, clients, tags (1-week expiry)
|
|
10
|
+
- [x] `--no-cache` flag to bypass cache
|
|
11
|
+
- [x] Current month CSV includes date in filename (e.g., `...-2026-09-17.csv`)
|
|
12
|
+
- [x] Auto-cleanup of old current-month files
|
|
13
|
+
- [x] Mutually exclusive `--from`, `--last`, `--refetch` options
|
|
14
|
+
- [x] Removed `last` from config file (only CLI)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 🔄 In Progress
|
|
19
|
+
|
|
20
|
+
- [ ] Auto-refresh cache when unknown project/client/tag ID encountered during entry processing
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 📋 Backlog
|
|
25
|
+
|
|
26
|
+
### Cache Improvements
|
|
27
|
+
- [ ] Add cache stats/debug command (`--cache-status`)
|
|
28
|
+
- [ ] Add config option for cache TTL (default 1 week)
|
|
29
|
+
- [ ] Cache workspaces list (currently only workspace data cached)
|
|
30
|
+
|
|
31
|
+
### Export Features
|
|
32
|
+
- [ ] Add `--dry-run` to show what would be exported without writing
|
|
33
|
+
- [ ] Add `--format json` output option
|
|
34
|
+
- [ ] Support exporting to single combined CSV (optionally)
|
|
35
|
+
|
|
36
|
+
### Reliability
|
|
37
|
+
- [ ] Add retry logic with exponential backoff for API requests
|
|
38
|
+
- [ ] Handle HTTP 429 rate limits gracefully (wait and retry)
|
|
39
|
+
|
|
40
|
+
### Developer Experience
|
|
41
|
+
- [ ] Add `--verbose` / `--quiet` flags
|
|
42
|
+
- [ ] Add progress bar for long exports
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## 🐛 Known Issues
|
|
47
|
+
|
|
48
|
+
- None currently
|
|
@@ -168,3 +168,55 @@ always consistent.
|
|
|
168
168
|
## License
|
|
169
169
|
|
|
170
170
|
MIT
|
|
171
|
+
|
|
172
|
+
## Schedule Daily Export via systemd
|
|
173
|
+
|
|
174
|
+
Use can use systemd timers to call the export regularily.
|
|
175
|
+
|
|
176
|
+
### Install with mise
|
|
177
|
+
|
|
178
|
+
[mise](https://mise.jdx.dev) can install the systemd unit automatically:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
mise install-systemd
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
This copies the service/timer files to `~/.config/systemd/user/` and enables the timer.
|
|
185
|
+
|
|
186
|
+
### Systemd Timer (daily export)
|
|
187
|
+
|
|
188
|
+
To run the export automatically every day as a systemd user unit:
|
|
189
|
+
|
|
190
|
+
#### 1. Install the service and timer
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
cp systemd/clockify-export.service "$HOME/.config/systemd/user/"
|
|
194
|
+
cp systemd/clockify-export.timer "$HOME/.config/systemd/user/"
|
|
195
|
+
systemctl --user daemon-reload
|
|
196
|
+
systemctl --user enable --now clockify-export.timer
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
#### 2. Set your API key
|
|
200
|
+
|
|
201
|
+
The unit reads the Clockify API key from the config file.
|
|
202
|
+
You can create one via `clockify-export --init-config` and ensure the
|
|
203
|
+
`api_key` is set in `~/.config/clockify-export/config.toml`.
|
|
204
|
+
|
|
205
|
+
#### 3. Manage the timer
|
|
206
|
+
|
|
207
|
+
The default timer is for daily export. Feel free to adjust `OnUnitActiveSec` if desired.
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# View status
|
|
211
|
+
systemctl --user status clockify-export.timer
|
|
212
|
+
systemctl --user status clockify-export.service
|
|
213
|
+
|
|
214
|
+
# Manual run
|
|
215
|
+
systemctl --user clockify-export
|
|
216
|
+
|
|
217
|
+
# Disable
|
|
218
|
+
systemctl --user disable --now clockify-export.timer
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
> Note: The service uses `Type=oneshot` so it completes and exits each day.
|
|
222
|
+
> Logs can be viewed with `journalctl --user -u clockify-export.service`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
if ! git diff --quiet || ! git diff --cached --quiet; then
|
|
4
|
+
echo "Error: Uncommitted changes detected. Commit or stash them first."
|
|
5
|
+
exit 1
|
|
6
|
+
fi
|
|
7
|
+
uv run pytest
|
|
8
|
+
uv version --bump $@
|
|
9
|
+
git add -A
|
|
10
|
+
version="v$(uv version --short)"
|
|
11
|
+
git commit -m "chore: release $version"
|
|
12
|
+
git tag "$version"
|
|
13
|
+
git push && git push --tags
|
|
@@ -11,4 +11,5 @@ export = "uv run clockify-export --output-dir ./export"
|
|
|
11
11
|
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
|
-
test = "uv run python -c \"from clockify_export.api import ClockifyAPI; print('import ok')\""
|
|
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"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "clockify-export"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
description = "Export Clockify time entries to CSV with incremental support"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -20,6 +20,12 @@ classifiers = [
|
|
|
20
20
|
]
|
|
21
21
|
dependencies = []
|
|
22
22
|
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest",
|
|
26
|
+
"pytest-mock",
|
|
27
|
+
]
|
|
28
|
+
|
|
23
29
|
[project.urls]
|
|
24
30
|
Homepage = "https://github.com/ptandler/clockify-export"
|
|
25
31
|
Repository = "https://github.com/ptandler/clockify-export"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""clockify-export — export Clockify time entries to CSV."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("clockify-export")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover - running from source without install
|
|
8
|
+
__version__ = "0.0.0"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Cache management for Clockify data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CACHE_DIR = ".cache"
|
|
11
|
+
DEFAULT_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 # 1 week
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_cache_dir(export_dir: Path) -> Path:
|
|
15
|
+
"""Get the cache directory path."""
|
|
16
|
+
return export_dir / CACHE_DIR
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _read_json(path: Path) -> dict | None:
|
|
20
|
+
"""Read JSON file, return None if not exists or invalid."""
|
|
21
|
+
if not path.exists():
|
|
22
|
+
return None
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(path.read_text())
|
|
25
|
+
except (json.JSONDecodeError, OSError):
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
30
|
+
"""Write JSON file, creating parent directories."""
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_cache(export_dir: Path, name: str, max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> dict | None:
|
|
36
|
+
"""Load cached data by name, return None if expired or missing."""
|
|
37
|
+
cache_dir = get_cache_dir(export_dir)
|
|
38
|
+
data = _read_json(cache_dir / f"{name}.json")
|
|
39
|
+
if data is None:
|
|
40
|
+
return None
|
|
41
|
+
# Check age
|
|
42
|
+
cached_time = data.get("_cached_at", 0)
|
|
43
|
+
if time.time() - cached_time > max_age_seconds:
|
|
44
|
+
return None
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_cache(export_dir: Path, name: str, data: dict) -> bool:
|
|
49
|
+
"""Save data to cache if changed. Returns True if written."""
|
|
50
|
+
cache_dir = get_cache_dir(export_dir)
|
|
51
|
+
path = cache_dir / f"{name}.json"
|
|
52
|
+
existing = _read_json(path)
|
|
53
|
+
if existing == data:
|
|
54
|
+
return False
|
|
55
|
+
# Add timestamp
|
|
56
|
+
data_with_ts = {"_cached_at": time.time(), **data}
|
|
57
|
+
_write_json(path, data_with_ts)
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def load_workspaces(export_dir: Path, max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> list[dict] | None:
|
|
62
|
+
"""Load cached workspaces."""
|
|
63
|
+
data = load_cache(export_dir, "workspaces", max_age_seconds)
|
|
64
|
+
return data.get("workspaces") if data else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def save_workspaces(export_dir: Path, workspaces: list[dict]) -> bool:
|
|
68
|
+
"""Save workspaces to cache if changed."""
|
|
69
|
+
return save_cache(export_dir, "workspaces", {"workspaces": workspaces})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_workspace_data(export_dir: Path, workspace_id: str, max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS) -> dict | None:
|
|
73
|
+
"""Load cached workspace data (projects, clients, tags)."""
|
|
74
|
+
return load_cache(export_dir, f"workspace_{workspace_id}", max_age_seconds)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_workspace_data(
|
|
78
|
+
export_dir: Path,
|
|
79
|
+
workspace_id: str,
|
|
80
|
+
projects: list[dict],
|
|
81
|
+
clients: list[dict],
|
|
82
|
+
tags: list[dict],
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""Save workspace data to cache if changed."""
|
|
85
|
+
data = {
|
|
86
|
+
"projects": projects,
|
|
87
|
+
"clients": clients,
|
|
88
|
+
"tags": tags,
|
|
89
|
+
}
|
|
90
|
+
return save_cache(export_dir, f"workspace_{workspace_id}", data)
|
|
@@ -7,14 +7,22 @@ import sys
|
|
|
7
7
|
from datetime import datetime, timedelta, timezone
|
|
8
8
|
|
|
9
9
|
from .api import ClockifyAPI, ClockifyError
|
|
10
|
-
from .config import CONFIG_FILE, Config, create_config_file, get_last_exported, set_last_exported
|
|
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 . import __version__
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
def main() -> None:
|
|
15
16
|
parser = argparse.ArgumentParser(
|
|
16
17
|
prog="clockify-export",
|
|
17
18
|
description="Export Clockify time entries to CSV",
|
|
19
|
+
epilog=CONFIG_HELP,
|
|
20
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--version",
|
|
24
|
+
action="version",
|
|
25
|
+
version=f"%(prog)s {__version__}",
|
|
18
26
|
)
|
|
19
27
|
parser.add_argument(
|
|
20
28
|
"--output-dir", "-o",
|
|
@@ -33,12 +41,27 @@ def main() -> None:
|
|
|
33
41
|
action="store_true",
|
|
34
42
|
help="Full export: ignore incremental state, export everything",
|
|
35
43
|
)
|
|
36
|
-
|
|
44
|
+
|
|
45
|
+
# Mutually exclusive date range options
|
|
46
|
+
date_group = parser.add_mutually_exclusive_group()
|
|
47
|
+
date_group.add_argument(
|
|
37
48
|
"--from",
|
|
38
49
|
dest="from_date",
|
|
39
50
|
type=str,
|
|
40
51
|
default=None,
|
|
41
|
-
help="Export entries from this date (YYYY-MM-DD). Overrides incremental state.",
|
|
52
|
+
help="Export entries from this date (YYYY-MM or YYYY-MM-DD). Always starts at beginning of month. Overrides incremental state.",
|
|
53
|
+
)
|
|
54
|
+
date_group.add_argument(
|
|
55
|
+
"--last",
|
|
56
|
+
type=int,
|
|
57
|
+
default=None,
|
|
58
|
+
help="Export the last N complete months (e.g., --last 3 exports last 3 complete months).",
|
|
59
|
+
)
|
|
60
|
+
date_group.add_argument(
|
|
61
|
+
"--refetch",
|
|
62
|
+
type=int,
|
|
63
|
+
default=None,
|
|
64
|
+
help="Re-fetch N months back from last exported date (can be set in config). Useful if entries were modified.",
|
|
42
65
|
)
|
|
43
66
|
parser.add_argument(
|
|
44
67
|
"--workspace",
|
|
@@ -56,6 +79,11 @@ def main() -> None:
|
|
|
56
79
|
action="store_true",
|
|
57
80
|
help="Create ~/.config/clockify-export/config.toml and exit",
|
|
58
81
|
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--no-cache",
|
|
84
|
+
action="store_true",
|
|
85
|
+
help="Bypass cache, fetch fresh projects/clients/tags from API",
|
|
86
|
+
)
|
|
59
87
|
args = parser.parse_args()
|
|
60
88
|
|
|
61
89
|
if args.init_config:
|
|
@@ -131,9 +159,21 @@ def main() -> None:
|
|
|
131
159
|
# Determine start date
|
|
132
160
|
since = None
|
|
133
161
|
if cfg.from_date:
|
|
134
|
-
# Explicit --from flag overrides everything
|
|
135
|
-
since =
|
|
136
|
-
print(f" --from: fetching since {
|
|
162
|
+
# Explicit --from flag overrides everything, always start at beginning of month
|
|
163
|
+
since = parse_from_date(cfg.from_date)
|
|
164
|
+
print(f" --from: fetching since {since.strftime('%Y-%m-%d')}")
|
|
165
|
+
elif args.last is not None and args.last > 0:
|
|
166
|
+
# --last N: export last N complete months
|
|
167
|
+
since = calculate_last_months(args.last)
|
|
168
|
+
print(f" --last {args.last}: fetching since {since.strftime('%Y-%m-%d')} (last {args.last} complete months)")
|
|
169
|
+
elif cfg.refetch > 0:
|
|
170
|
+
# --refetch N: re-fetch N months back from last exported date
|
|
171
|
+
last = get_last_exported(ws_id)
|
|
172
|
+
since = calculate_refetch_date(cfg.refetch, last)
|
|
173
|
+
if since:
|
|
174
|
+
print(f" --refetch {cfg.refetch}: re-fetching from {since.strftime('%Y-%m-%d')} (last exported: {last})")
|
|
175
|
+
else:
|
|
176
|
+
print(f" --refetch {cfg.refetch}: no prior export found, skipping refetch")
|
|
137
177
|
elif not cfg.full:
|
|
138
178
|
last = get_last_exported(ws_id)
|
|
139
179
|
if last:
|
|
@@ -146,16 +186,14 @@ def main() -> None:
|
|
|
146
186
|
print(" Full export mode")
|
|
147
187
|
|
|
148
188
|
try:
|
|
149
|
-
counts = export_workspace(
|
|
189
|
+
counts, latest_entry_dt = export_workspace(
|
|
190
|
+
api, ws_id, ws_name, output_dir, since=since,
|
|
191
|
+
use_cache=not args.no_cache,
|
|
192
|
+
)
|
|
150
193
|
if counts:
|
|
151
|
-
# Update state to the latest entry date
|
|
152
|
-
|
|
153
|
-
|
|
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())
|
|
194
|
+
# Update state to the actual latest entry date (not end of month)
|
|
195
|
+
if latest_entry_dt:
|
|
196
|
+
set_last_exported(ws_id, latest_entry_dt.isoformat())
|
|
159
197
|
total = sum(counts.values())
|
|
160
198
|
print(f" Total: {total} entries across {len(counts)} month(s)")
|
|
161
199
|
else:
|
|
@@ -25,8 +25,22 @@ STATE_FILE = CONFIG_DIR / "state.json"
|
|
|
25
25
|
DEFAULTS = {
|
|
26
26
|
"api_key": None,
|
|
27
27
|
"export_dir": "export",
|
|
28
|
+
"refetch": 1,
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
CONFIG_HELP = """
|
|
32
|
+
Supported configuration options (config.toml):
|
|
33
|
+
api_key - Clockify API key (required)
|
|
34
|
+
export_dir - Output directory for CSV files (default: "export")
|
|
35
|
+
refetch - Number of months to re-fetch from last exported date (default: 1)
|
|
36
|
+
|
|
37
|
+
Example config.toml:
|
|
38
|
+
# Clockify export configuration
|
|
39
|
+
api_key = "YOUR_API_KEY"
|
|
40
|
+
export_dir = "export"
|
|
41
|
+
refetch = 1
|
|
42
|
+
"""
|
|
43
|
+
|
|
30
44
|
|
|
31
45
|
@dataclass
|
|
32
46
|
class Config:
|
|
@@ -35,6 +49,7 @@ class Config:
|
|
|
35
49
|
full: bool = False
|
|
36
50
|
workspaces: list[str] = field(default_factory=list)
|
|
37
51
|
from_date: str | None = None
|
|
52
|
+
refetch: int = 0
|
|
38
53
|
|
|
39
54
|
@classmethod
|
|
40
55
|
def load(cls, args=None) -> Config:
|
|
@@ -61,6 +76,12 @@ class Config:
|
|
|
61
76
|
full = getattr(args, "full", False) if args else False
|
|
62
77
|
workspaces = getattr(args, "workspace", []) if args else []
|
|
63
78
|
from_date = getattr(args, "from_date", None) if args else None
|
|
79
|
+
|
|
80
|
+
# For refetch: CLI arg wins, then config file, then default
|
|
81
|
+
if args and getattr(args, "refetch", None) is not None:
|
|
82
|
+
refetch = args.refetch
|
|
83
|
+
else:
|
|
84
|
+
refetch = file_cfg.get("refetch", DEFAULTS["refetch"])
|
|
64
85
|
|
|
65
86
|
return cls(
|
|
66
87
|
api_key=api_key or "",
|
|
@@ -68,6 +89,7 @@ class Config:
|
|
|
68
89
|
full=full,
|
|
69
90
|
workspaces=workspaces or [],
|
|
70
91
|
from_date=from_date,
|
|
92
|
+
refetch=refetch,
|
|
71
93
|
)
|
|
72
94
|
|
|
73
95
|
|
|
@@ -111,10 +133,48 @@ def create_config_file(api_key: str = "", export_dir: str = "export") -> Path:
|
|
|
111
133
|
"# Clockify export configuration\n"
|
|
112
134
|
f'api_key = "{api_key}"\n'
|
|
113
135
|
f'export_dir = "{export_dir}"\n'
|
|
136
|
+
"# Number of months to re-fetch from last exported date (default: 1)\n"
|
|
137
|
+
"refetch = 1\n"
|
|
114
138
|
)
|
|
115
139
|
return CONFIG_FILE
|
|
116
140
|
|
|
117
141
|
|
|
142
|
+
def parse_from_date(date_str: str) -> datetime:
|
|
143
|
+
"""Parse --from date string (YYYY-MM or YYYY-MM-DD) and return first day of that month."""
|
|
144
|
+
parts = date_str.split("-")
|
|
145
|
+
if len(parts) < 2:
|
|
146
|
+
raise ValueError(f"Invalid date format: {date_str}. Use YYYY-MM or YYYY-MM-DD")
|
|
147
|
+
year = int(parts[0])
|
|
148
|
+
month = int(parts[1])
|
|
149
|
+
return datetime(year, month, 1, tzinfo=timezone.utc)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def calculate_last_months(last: int) -> datetime:
|
|
153
|
+
"""Calculate the first day of the month that is 'last' complete months ago."""
|
|
154
|
+
now = datetime.now(timezone.utc)
|
|
155
|
+
# Go back 'last' months from the current month, then get the first day of that month
|
|
156
|
+
# If last=1, we want last complete month (not current month)
|
|
157
|
+
year = now.year
|
|
158
|
+
month = now.month - last
|
|
159
|
+
while month <= 0:
|
|
160
|
+
month += 12
|
|
161
|
+
year -= 1
|
|
162
|
+
return datetime(year, month, 1, tzinfo=timezone.utc)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def calculate_refetch_date(refetch: int, last_exported: str | None) -> datetime | None:
|
|
166
|
+
"""Calculate the date to start re-fetching from, going back 'refetch' months from last exported."""
|
|
167
|
+
if not last_exported or refetch <= 0:
|
|
168
|
+
return None
|
|
169
|
+
last_dt = datetime.fromisoformat(last_exported)
|
|
170
|
+
year = last_dt.year
|
|
171
|
+
month = last_dt.month - refetch
|
|
172
|
+
while month <= 0:
|
|
173
|
+
month += 12
|
|
174
|
+
year -= 1
|
|
175
|
+
return datetime(year, month, 1, tzinfo=timezone.utc)
|
|
176
|
+
|
|
177
|
+
|
|
118
178
|
def get_state() -> dict:
|
|
119
179
|
if STATE_FILE.exists():
|
|
120
180
|
try:
|