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.
Files changed (34) hide show
  1. {clockify_export-0.1.1 → clockify_export-0.1.5}/.gitignore +1 -0
  2. clockify_export-0.1.5/AGENTS.md +66 -0
  3. clockify_export-0.1.5/CHANGELOG.md +38 -0
  4. {clockify_export-0.1.1 → clockify_export-0.1.5}/PKG-INFO +56 -1
  5. clockify_export-0.1.5/PLAN.md +48 -0
  6. {clockify_export-0.1.1 → clockify_export-0.1.5}/README.md +52 -0
  7. clockify_export-0.1.5/mise-tasks/bump-version +13 -0
  8. {clockify_export-0.1.1 → clockify_export-0.1.5}/mise.toml +2 -1
  9. {clockify_export-0.1.1 → clockify_export-0.1.5}/pyproject.toml +7 -1
  10. clockify_export-0.1.5/src/clockify_export/__init__.py +8 -0
  11. clockify_export-0.1.5/src/clockify_export/cache.py +90 -0
  12. {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/cli.py +53 -15
  13. {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/config.py +60 -0
  14. {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/export.py +66 -14
  15. clockify_export-0.1.5/systemd/clockify-export.service +8 -0
  16. clockify_export-0.1.5/systemd/clockify-export.timer +10 -0
  17. clockify_export-0.1.5/tests/conftest.py +155 -0
  18. clockify_export-0.1.5/tests/test_api.py +55 -0
  19. clockify_export-0.1.5/tests/test_cache.py +146 -0
  20. clockify_export-0.1.5/tests/test_cli.py +237 -0
  21. clockify_export-0.1.5/tests/test_config.py +201 -0
  22. clockify_export-0.1.5/tests/test_export.py +350 -0
  23. clockify_export-0.1.5/uv.lock +94 -0
  24. clockify_export-0.1.1/.idea/.gitignore +0 -8
  25. clockify_export-0.1.1/.idea/clockify-export.iml +0 -9
  26. clockify_export-0.1.1/.idea/misc.xml +0 -6
  27. clockify_export-0.1.1/.idea/modules.xml +0 -8
  28. clockify_export-0.1.1/.idea/vcs.xml +0 -6
  29. clockify_export-0.1.1/src/clockify_export/__init__.py +0 -0
  30. clockify_export-0.1.1/uv.lock +0 -8
  31. {clockify_export-0.1.1 → clockify_export-0.1.5}/.env.example +0 -0
  32. {clockify_export-0.1.1 → clockify_export-0.1.5}/.github/workflows/publish.yml +0 -0
  33. {clockify_export-0.1.1 → clockify_export-0.1.5}/LICENSE +0 -0
  34. {clockify_export-0.1.1 → clockify_export-0.1.5}/src/clockify_export/api.py +0 -0
@@ -6,3 +6,4 @@ dist/
6
6
  *.egg-info/
7
7
 
8
8
  export/
9
+ .idea/
@@ -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.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.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
- parser.add_argument(
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 = datetime.strptime(cfg.from_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
136
- print(f" --from: fetching since {cfg.from_date}")
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(api, ws_id, ws_name, output_dir, since=since)
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
- latest_month = max(counts.keys())
153
- latest_ts = datetime.strptime(latest_month, "%Y-%m").replace(tzinfo=timezone.utc)
154
- # Set to end of month to mark the whole month as exported
155
- from calendar import monthrange
156
- _, last_day = monthrange(latest_ts.year, latest_ts.month)
157
- latest_ts = latest_ts.replace(day=last_day)
158
- set_last_exported(ws_id, latest_ts.isoformat())
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: