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.
Files changed (31) hide show
  1. {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/clockify-export.iml +3 -1
  2. clockify_export-0.1.4/.idea/git_toolbox_prj.xml +15 -0
  3. clockify_export-0.1.4/.idea/go.imports.xml +10 -0
  4. clockify_export-0.1.4/.idea/jsonSchemas.xml +25 -0
  5. clockify_export-0.1.4/.idea/misc.xml +9 -0
  6. clockify_export-0.1.4/AGENTS.md +54 -0
  7. clockify_export-0.1.4/CHANGELOG.md +38 -0
  8. {clockify_export-0.1.1 → clockify_export-0.1.4}/PKG-INFO +53 -1
  9. clockify_export-0.1.4/PLAN.md +48 -0
  10. {clockify_export-0.1.1 → clockify_export-0.1.4}/README.md +52 -0
  11. clockify_export-0.1.4/mise-tasks/bump-version +7 -0
  12. {clockify_export-0.1.1 → clockify_export-0.1.4}/mise.toml +2 -1
  13. {clockify_export-0.1.1 → clockify_export-0.1.4}/pyproject.toml +1 -1
  14. clockify_export-0.1.4/src/clockify_export/__init__.py +8 -0
  15. clockify_export-0.1.4/src/clockify_export/cache.py +90 -0
  16. {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/cli.py +53 -15
  17. {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/config.py +60 -0
  18. {clockify_export-0.1.1 → clockify_export-0.1.4}/src/clockify_export/export.py +66 -14
  19. clockify_export-0.1.4/systemd/clockify-export.service +8 -0
  20. clockify_export-0.1.4/systemd/clockify-export.timer +10 -0
  21. {clockify_export-0.1.1 → clockify_export-0.1.4}/uv.lock +1 -1
  22. clockify_export-0.1.1/.idea/misc.xml +0 -6
  23. clockify_export-0.1.1/src/clockify_export/__init__.py +0 -0
  24. {clockify_export-0.1.1 → clockify_export-0.1.4}/.env.example +0 -0
  25. {clockify_export-0.1.1 → clockify_export-0.1.4}/.github/workflows/publish.yml +0 -0
  26. {clockify_export-0.1.1 → clockify_export-0.1.4}/.gitignore +0 -0
  27. {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/.gitignore +0 -0
  28. {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/modules.xml +0 -0
  29. {clockify_export-0.1.1 → clockify_export-0.1.4}/.idea/vcs.xml +0 -0
  30. {clockify_export-0.1.1 → clockify_export-0.1.4}/LICENSE +0 -0
  31. {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,10 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="GoImports">
4
+ <option name="excludedPackages">
5
+ <array>
6
+ <option value="golang.org/x/net/context" />
7
+ </array>
8
+ </option>
9
+ </component>
10
+ </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.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`.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ uv version --bump $@
3
+ uv run pytest
4
+ git add -A
5
+ git commit -m "chore: release v$(uv version --short)"
6
+ git tag "v$(uv version --short)"
7
+ 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.4"
4
4
  description = "Export Clockify time entries to CSV with incremental support"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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:
@@ -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
- ) -> dict[str, int]:
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 dict of {YYYY-MM: row_count}.
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
- # Fetch lookup tables
67
- print(" Fetching projects...")
68
- projects = {p["id"]: p for p in api.get_projects(workspace_id)}
69
- print(" Fetching clients...")
70
- clients = {c["id"]: c for c in api.get_clients(workspace_id)}
71
- print(" Fetching tags...")
72
- tags = {t["id"]: t for t in api.get_tags(workspace_id)}
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
- # Filename carries workspace + year so files stay identifiable when moved
113
- csv_path = year_dir / f"{safe_ws_name}_{year}-{month}.csv"
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
@@ -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
@@ -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
@@ -4,5 +4,5 @@ requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
6
6
  name = "clockify-export"
7
- version = "0.1.1"
7
+ version = "0.1.4"
8
8
  source = { editable = "." }
@@ -1,6 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <project version="4">
3
- <component name="ProjectRootManager">
4
- <output url="file://$PROJECT_DIR$/out" />
5
- </component>
6
- </project>
File without changes
File without changes