clockify-export 0.1.1__tar.gz → 0.1.4__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.4}/.idea/clockify-export.iml +3 -1
- clockify_export-0.1.4/.idea/git_toolbox_prj.xml +15 -0
- clockify_export-0.1.4/.idea/go.imports.xml +10 -0
- clockify_export-0.1.4/.idea/jsonSchemas.xml +25 -0
- clockify_export-0.1.4/.idea/misc.xml +9 -0
- clockify_export-0.1.4/AGENTS.md +54 -0
- clockify_export-0.1.4/CHANGELOG.md +38 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/PKG-INFO +53 -1
- clockify_export-0.1.4/PLAN.md +48 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/README.md +52 -0
- clockify_export-0.1.4/mise-tasks/bump-version +7 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/mise.toml +2 -1
- {clockify_export-0.1.1 → clockify_export-0.1.4}/pyproject.toml +1 -1
- clockify_export-0.1.4/src/clockify_export/__init__.py +8 -0
- clockify_export-0.1.4/src/clockify_export/cache.py +90 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/cli.py +53 -15
- {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/config.py +60 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/export.py +66 -14
- clockify_export-0.1.4/systemd/clockify-export.service +8 -0
- clockify_export-0.1.4/systemd/clockify-export.timer +10 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/uv.lock +1 -1
- clockify_export-0.1.1/.idea/misc.xml +0 -6
- clockify_export-0.1.1/src/clockify_export/__init__.py +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.env.example +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.github/workflows/publish.yml +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.gitignore +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/.gitignore +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/modules.xml +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/vcs.xml +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/LICENSE +0 -0
- {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/api.py +0 -0
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
<module type="JAVA_MODULE" version="4">
|
|
3
3
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
4
4
|
<exclude-output />
|
|
5
|
-
<content url="file://$MODULE_DIR$"
|
|
5
|
+
<content url="file://$MODULE_DIR$">
|
|
6
|
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
7
|
+
</content>
|
|
6
8
|
<orderEntry type="inheritedJdk" />
|
|
7
9
|
<orderEntry type="sourceFolder" forTests="false" />
|
|
8
10
|
</component>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="GitToolBoxProjectSettings">
|
|
4
|
+
<option name="commitMessageIssueKeyValidationOverride">
|
|
5
|
+
<BoolValueOverride>
|
|
6
|
+
<option name="enabled" value="true" />
|
|
7
|
+
</BoolValueOverride>
|
|
8
|
+
</option>
|
|
9
|
+
<option name="commitMessageValidationEnabledOverride">
|
|
10
|
+
<BoolValueOverride>
|
|
11
|
+
<option name="enabled" value="true" />
|
|
12
|
+
</BoolValueOverride>
|
|
13
|
+
</option>
|
|
14
|
+
</component>
|
|
15
|
+
</project>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="JsonSchemaMappingsProjectConfiguration">
|
|
4
|
+
<state>
|
|
5
|
+
<map>
|
|
6
|
+
<entry key="mise">
|
|
7
|
+
<value>
|
|
8
|
+
<SchemaInfo>
|
|
9
|
+
<option name="name" value="mise" />
|
|
10
|
+
<option name="relativePathToSchema" value="https://mise.jdx.dev/schema/mise.json" />
|
|
11
|
+
<option name="applicationDefined" value="true" />
|
|
12
|
+
<option name="patterns">
|
|
13
|
+
<list>
|
|
14
|
+
<Item>
|
|
15
|
+
<option name="path" value="mise.toml" />
|
|
16
|
+
</Item>
|
|
17
|
+
</list>
|
|
18
|
+
</option>
|
|
19
|
+
</SchemaInfo>
|
|
20
|
+
</value>
|
|
21
|
+
</entry>
|
|
22
|
+
</map>
|
|
23
|
+
</state>
|
|
24
|
+
</component>
|
|
25
|
+
</project>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="Black">
|
|
4
|
+
<option name="sdkName" value="Python 3.14" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ProjectRootManager" version="2" languageLevel="JDK_25" project-jdk-name="Python 3.14" project-jdk-type="Python SDK">
|
|
7
|
+
<output url="file://$PROJECT_DIR$/out" />
|
|
8
|
+
</component>
|
|
9
|
+
</project>
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
3. Keep `[Unreleased]` section for things done since last release
|
|
31
|
+
4. 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` |
|
|
@@ -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.4
|
|
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
|
|
@@ -191,3 +191,55 @@ always consistent.
|
|
|
191
191
|
## License
|
|
192
192
|
|
|
193
193
|
MIT
|
|
194
|
+
|
|
195
|
+
## Schedule Daily Export via systemd
|
|
196
|
+
|
|
197
|
+
Use can use systemd timers to call the export regularily.
|
|
198
|
+
|
|
199
|
+
### Install with mise
|
|
200
|
+
|
|
201
|
+
[mise](https://mise.jdx.dev) can install the systemd unit automatically:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
mise install-systemd
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
This copies the service/timer files to `~/.config/systemd/user/` and enables the timer.
|
|
208
|
+
|
|
209
|
+
### Systemd Timer (daily export)
|
|
210
|
+
|
|
211
|
+
To run the export automatically every day as a systemd user unit:
|
|
212
|
+
|
|
213
|
+
#### 1. Install the service and timer
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
cp systemd/clockify-export.service "$HOME/.config/systemd/user/"
|
|
217
|
+
cp systemd/clockify-export.timer "$HOME/.config/systemd/user/"
|
|
218
|
+
systemctl --user daemon-reload
|
|
219
|
+
systemctl --user enable --now clockify-export.timer
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
#### 2. Set your API key
|
|
223
|
+
|
|
224
|
+
The unit reads the Clockify API key from the config file.
|
|
225
|
+
You can create one via `clockify-export --init-config` and ensure the
|
|
226
|
+
`api_key` is set in `~/.config/clockify-export/config.toml`.
|
|
227
|
+
|
|
228
|
+
#### 3. Manage the timer
|
|
229
|
+
|
|
230
|
+
The default timer is for daily export. Feel free to adjust `OnUnitActiveSec` if desired.
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
# View status
|
|
234
|
+
systemctl --user status clockify-export.timer
|
|
235
|
+
systemctl --user status clockify-export.service
|
|
236
|
+
|
|
237
|
+
# Manual run
|
|
238
|
+
systemctl --user clockify-export
|
|
239
|
+
|
|
240
|
+
# Disable
|
|
241
|
+
systemctl --user disable --now clockify-export.timer
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
> Note: The service uses `Type=oneshot` so it completes and exits each day.
|
|
245
|
+
> 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`.
|
|
@@ -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"
|
|
@@ -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:
|
|
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import csv
|
|
6
6
|
import re
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
from .api import ClockifyAPI, parse_iso_duration_to_hours
|
|
11
|
+
from .cache import load_workspace_data, save_workspace_data, DEFAULT_MAX_AGE_SECONDS
|
|
11
12
|
|
|
12
13
|
CSV_HEADERS = [
|
|
13
14
|
"Date",
|
|
@@ -46,30 +47,67 @@ def _sanitize_dirname(name: str) -> str:
|
|
|
46
47
|
return re.sub(r'[<>:"/\\|?*]', "_", name).strip()
|
|
47
48
|
|
|
48
49
|
|
|
50
|
+
def _is_current_month(year: str, month: str) -> bool:
|
|
51
|
+
"""Check if the given year/month is the current month."""
|
|
52
|
+
now = datetime.now(timezone.utc)
|
|
53
|
+
return int(year) == now.year and int(month) == now.month
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cleanup_old_current_month_files(year_dir: Path, safe_ws_name: str, year: str, month: str, current_date_str: str) -> None:
|
|
57
|
+
"""Remove old current-month files with different dates."""
|
|
58
|
+
pattern = f"{safe_ws_name}_{year}-{month}-*.csv"
|
|
59
|
+
for old_file in year_dir.glob(pattern):
|
|
60
|
+
if current_date_str not in old_file.name:
|
|
61
|
+
print(f" Removing old current-month file: {old_file.relative_to(year_dir.parent.parent)}")
|
|
62
|
+
old_file.unlink(missing_ok=True)
|
|
63
|
+
|
|
64
|
+
|
|
49
65
|
def export_workspace(
|
|
50
66
|
api: ClockifyAPI,
|
|
51
67
|
workspace_id: str,
|
|
52
68
|
workspace_name: str,
|
|
53
69
|
output_dir: Path,
|
|
54
70
|
since: datetime | None = None,
|
|
55
|
-
|
|
71
|
+
use_cache: bool = True,
|
|
72
|
+
max_age_seconds: int = DEFAULT_MAX_AGE_SECONDS,
|
|
73
|
+
) -> tuple[dict[str, int], datetime | None]:
|
|
56
74
|
"""Export all time entries for a workspace, grouped by year/month.
|
|
57
75
|
|
|
58
76
|
Directory structure: {output_dir}/{workspace_name}/{year}/{MM}.csv
|
|
59
|
-
Returns
|
|
77
|
+
Returns (counts_dict, latest_entry_date) where latest_entry_date is the
|
|
78
|
+
most recent entry's start time (or None if no entries).
|
|
60
79
|
"""
|
|
61
80
|
# Resolve current user
|
|
62
81
|
user = api.get_current_user()
|
|
63
82
|
user_id = user["id"]
|
|
64
83
|
print(f" User: {user.get('name', user_id)}")
|
|
65
84
|
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
# Try to load from cache first
|
|
86
|
+
if use_cache:
|
|
87
|
+
cached = load_workspace_data(output_dir, workspace_id, max_age_seconds)
|
|
88
|
+
if cached:
|
|
89
|
+
projects = {p["id"]: p for p in cached.get("projects", [])}
|
|
90
|
+
clients = {c["id"]: c for c in cached.get("clients", [])}
|
|
91
|
+
tags = {t["id"]: t for t in cached.get("tags", [])}
|
|
92
|
+
print(" Using cached projects, clients, tags")
|
|
93
|
+
else:
|
|
94
|
+
# Fetch lookup tables
|
|
95
|
+
print(" Fetching projects...")
|
|
96
|
+
projects = {p["id"]: p for p in api.get_projects(workspace_id)}
|
|
97
|
+
print(" Fetching clients...")
|
|
98
|
+
clients = {c["id"]: c for c in api.get_clients(workspace_id)}
|
|
99
|
+
print(" Fetching tags...")
|
|
100
|
+
tags = {t["id"]: t for t in api.get_tags(workspace_id)}
|
|
101
|
+
# Save to cache
|
|
102
|
+
save_workspace_data(output_dir, workspace_id, list(projects.values()), list(clients.values()), list(tags.values()))
|
|
103
|
+
else:
|
|
104
|
+
# Fetch lookup tables (no cache)
|
|
105
|
+
print(" Fetching projects...")
|
|
106
|
+
projects = {p["id"]: p for p in api.get_projects(workspace_id)}
|
|
107
|
+
print(" Fetching clients...")
|
|
108
|
+
clients = {c["id"]: c for c in api.get_clients(workspace_id)}
|
|
109
|
+
print(" Fetching tags...")
|
|
110
|
+
tags = {t["id"]: t for t in api.get_tags(workspace_id)}
|
|
73
111
|
|
|
74
112
|
# Fetch time entries first to know which projects have entries
|
|
75
113
|
print(" Fetching time entries...")
|
|
@@ -91,13 +129,16 @@ def export_workspace(
|
|
|
91
129
|
if client_id and client_id in clients:
|
|
92
130
|
project_client[p["id"]] = clients[client_id].get("name", "")
|
|
93
131
|
|
|
94
|
-
# Group by year/month
|
|
132
|
+
# Group by year/month, track latest entry date
|
|
95
133
|
by_month: dict[str, list[dict]] = {}
|
|
134
|
+
latest_entry_dt: datetime | None = None
|
|
96
135
|
for entry in entries:
|
|
97
136
|
start_str = entry.get("timeInterval", {}).get("start")
|
|
98
137
|
if not start_str:
|
|
99
138
|
continue
|
|
100
139
|
dt = datetime.fromisoformat(start_str)
|
|
140
|
+
if latest_entry_dt is None or dt > latest_entry_dt:
|
|
141
|
+
latest_entry_dt = dt
|
|
101
142
|
month_key = dt.strftime("%Y-%m")
|
|
102
143
|
by_month.setdefault(month_key, []).append(entry)
|
|
103
144
|
|
|
@@ -105,12 +146,19 @@ def export_workspace(
|
|
|
105
146
|
safe_ws_name = _sanitize_dirname(workspace_name)
|
|
106
147
|
ws_dir = output_dir / safe_ws_name
|
|
107
148
|
counts: dict[str, int] = {}
|
|
149
|
+
now = datetime.now(timezone.utc)
|
|
150
|
+
current_date_str = now.strftime("%Y-%m-%d")
|
|
108
151
|
for month_key, month_entries in sorted(by_month.items()):
|
|
109
152
|
year, month = month_key.split("-")
|
|
110
153
|
year_dir = ws_dir / year
|
|
111
154
|
year_dir.mkdir(parents=True, exist_ok=True)
|
|
112
|
-
|
|
113
|
-
|
|
155
|
+
|
|
156
|
+
# For current month, include date in filename; otherwise just year-month
|
|
157
|
+
if _is_current_month(year, month):
|
|
158
|
+
csv_filename = f"{safe_ws_name}_{year}-{month}-{current_date_str}.csv"
|
|
159
|
+
else:
|
|
160
|
+
csv_filename = f"{safe_ws_name}_{year}-{month}.csv"
|
|
161
|
+
csv_path = year_dir / csv_filename
|
|
114
162
|
|
|
115
163
|
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
|
116
164
|
writer = csv.writer(f)
|
|
@@ -150,7 +198,11 @@ def export_workspace(
|
|
|
150
198
|
(entry.get("timeInterval") or {}).get("timeZone", ""),
|
|
151
199
|
])
|
|
152
200
|
|
|
201
|
+
# Clean up old current-month files if this is the current month
|
|
202
|
+
if _is_current_month(year, month):
|
|
203
|
+
_cleanup_old_current_month_files(year_dir, safe_ws_name, year, month, current_date_str)
|
|
204
|
+
|
|
153
205
|
counts[month_key] = len(month_entries)
|
|
154
206
|
print(f" Wrote {csv_path.relative_to(output_dir)} ({len(month_entries)} entries)")
|
|
155
207
|
|
|
156
|
-
return counts
|
|
208
|
+
return counts, latest_entry_dt
|
|
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
|