timing-cli 0.1.0__py3-none-any.whl

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.
timing_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """timing-cli: read the local Timing.app database and generate/push time entries."""
2
+
3
+ __version__ = "0.1.0"
timing_cli/analysis.py ADDED
@@ -0,0 +1,161 @@
1
+ """Aggregate raw app usage into project time blocks and daily summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from datetime import datetime, time, timedelta
7
+
8
+ from timing_cli.models import AppUsage, ProjectSummary, TimeEntrySuggestion
9
+ from timing_cli.rules import UNASSIGNED, Classification, Classifier
10
+
11
+
12
+ def _local_day(dt: datetime) -> str:
13
+ return dt.strftime("%Y-%m-%d")
14
+
15
+
16
+ def _next_local_midnight(dt: datetime) -> datetime:
17
+ return datetime.combine(dt.date() + timedelta(days=1), time.min, tzinfo=dt.tzinfo)
18
+
19
+
20
+ def _split_at_local_midnight(start: datetime, end: datetime) -> list[tuple[datetime, datetime]]:
21
+ pieces: list[tuple[datetime, datetime]] = []
22
+ current = start
23
+ while current < end:
24
+ next_midnight = _next_local_midnight(current)
25
+ piece_end = min(end, next_midnight)
26
+ pieces.append((current, piece_end))
27
+ current = piece_end
28
+ return pieces
29
+
30
+
31
+ def summarize_by_project(
32
+ usage: list[AppUsage],
33
+ classifier: Classifier,
34
+ include_unassigned: bool = True,
35
+ ) -> list[ProjectSummary]:
36
+ """Total tracked seconds per project across the given usage."""
37
+ totals: dict[tuple[int | None, str], ProjectSummary] = {}
38
+ for slice_ in usage:
39
+ c = classifier.classify(slice_)
40
+ if not include_unassigned and c.project_title == UNASSIGNED:
41
+ continue
42
+ key = (c.project_id, c.project_title)
43
+ summary = totals.get(key)
44
+ if summary is None:
45
+ summary = ProjectSummary(project_id=c.project_id, project_title=c.project_title)
46
+ totals[key] = summary
47
+ summary.seconds += slice_.duration_seconds
48
+ summary.entries += 1
49
+ return sorted(totals.values(), key=lambda s: s.seconds, reverse=True)
50
+
51
+
52
+ def _build_entry(
53
+ classification: Classification,
54
+ start: datetime,
55
+ end: datetime,
56
+ app_counter: Counter[str],
57
+ ) -> TimeEntrySuggestion:
58
+ top = [app for app, _ in app_counter.most_common(3)]
59
+ title = classification.project_title
60
+ if top:
61
+ title = f"{classification.project_title}: {', '.join(top)}"
62
+ notes = "Auto-generated from Timing app usage. Top apps: " + ", ".join(
63
+ f"{app} ({count})" for app, count in app_counter.most_common(5)
64
+ )
65
+ return TimeEntrySuggestion(
66
+ day=_local_day(start),
67
+ start=start,
68
+ end=end,
69
+ project_id=classification.project_id,
70
+ project_title=classification.project_title,
71
+ project_title_chain=list(
72
+ classification.project_title_chain or (classification.project_title,)
73
+ ),
74
+ title=title,
75
+ notes=notes,
76
+ source_count=sum(app_counter.values()),
77
+ top_apps=top,
78
+ )
79
+
80
+
81
+ def aggregate(
82
+ usage: list[AppUsage],
83
+ classifier: Classifier,
84
+ min_block_seconds: int = 120,
85
+ gap_merge_seconds: int = 300,
86
+ include_unassigned: bool = False,
87
+ ) -> list[TimeEntrySuggestion]:
88
+ """Merge chronological same-project slices into time-entry suggestions.
89
+
90
+ Blocks are built in wall-clock order and never cross local-day boundaries.
91
+ Same-project slices are merged when no other included project appears
92
+ between them and the wall-clock gap does not exceed ``gap_merge_seconds``.
93
+ Skipped unassigned slices behave like idle gaps; different included projects
94
+ always break the current block. Overlapping included slices are clipped to
95
+ the already-consumed cursor so suggestions cannot overlap.
96
+ """
97
+ suggestions: list[TimeEntrySuggestion] = []
98
+ block_start: datetime | None = None
99
+ block_end: datetime | None = None
100
+ block_class: Classification | None = None
101
+ apps: Counter[str] = Counter()
102
+ cursor: datetime | None = None
103
+
104
+ def flush() -> None:
105
+ nonlocal block_start, block_end, block_class, apps
106
+ if block_start is None or block_end is None or block_class is None:
107
+ return
108
+ if (block_end - block_start).total_seconds() >= min_block_seconds:
109
+ suggestions.append(_build_entry(block_class, block_start, block_end, apps))
110
+ block_start = block_end = block_class = None
111
+ apps = Counter()
112
+
113
+ def start_block(
114
+ classification: Classification,
115
+ start: datetime,
116
+ end: datetime,
117
+ app: str,
118
+ ) -> None:
119
+ nonlocal block_start, block_end, block_class, apps
120
+ block_start = start
121
+ block_end = end
122
+ block_class = classification
123
+ apps = Counter({app: 1})
124
+
125
+ for slice_ in sorted(usage, key=lambda u: (u.start, u.end, u.id)):
126
+ if slice_.end <= slice_.start:
127
+ continue
128
+
129
+ c = classifier.classify(slice_)
130
+ if not include_unassigned and c.project_title == UNASSIGNED:
131
+ continue
132
+
133
+ for segment_start, segment_end in _split_at_local_midnight(slice_.start, slice_.end):
134
+ if cursor is not None and segment_start < cursor:
135
+ segment_start = cursor
136
+ if segment_end <= segment_start:
137
+ continue
138
+ cursor = segment_end
139
+
140
+ if block_class is None or block_end is None or block_start is None:
141
+ start_block(c, segment_start, segment_end, slice_.app)
142
+ continue
143
+
144
+ same_project = (block_class.project_id, block_class.project_title) == (
145
+ c.project_id,
146
+ c.project_title,
147
+ )
148
+ same_day = _local_day(block_start) == _local_day(segment_start)
149
+ gap_seconds = (segment_start - block_end).total_seconds()
150
+ if not same_project or not same_day or gap_seconds > gap_merge_seconds:
151
+ flush()
152
+ start_block(c, segment_start, segment_end, slice_.app)
153
+ continue
154
+
155
+ block_end = max(block_end, segment_end)
156
+ apps[slice_.app] += 1
157
+
158
+ flush()
159
+
160
+ suggestions.sort(key=lambda s: s.start)
161
+ return suggestions
timing_cli/api.py ADDED
@@ -0,0 +1,311 @@
1
+ """Client for the Timing Web API (https://web.timingapp.com/docs/).
2
+
3
+ The Web API is the safe write path: it can *create* time entries even though it
4
+ cannot *read* local app usage. We never write to the local SQLite store, which
5
+ would risk corrupting Timing's Core-Data invariants and its sync engine.
6
+
7
+ Auth is a bearer token from https://web.timingapp.com/integrations/tokens,
8
+ supplied via ``TIMING_API_KEY`` or the config file.
9
+
10
+ Payload shapes follow Timing Web API v1: time entries take ``start_date``,
11
+ ``end_date``, ``project`` (a project self-reference such as ``/projects/1``),
12
+ ``title``, ``notes`` and ``replace_existing``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from datetime import datetime
18
+ from typing import Any
19
+
20
+ import httpx
21
+
22
+
23
+ class TimingApiError(RuntimeError):
24
+ """Raised when the Timing Web API returns an error or no token is set."""
25
+
26
+
27
+ class TimingApiClient:
28
+ def __init__(self, base_url: str, token: str | None, timeout: float = 30.0) -> None:
29
+ if not token:
30
+ raise TimingApiError(
31
+ "No Timing API token. Set TIMING_API_KEY or api_token in the config. "
32
+ "Create one at https://web.timingapp.com/integrations/tokens"
33
+ )
34
+ self._base_url = base_url.rstrip("/")
35
+ self._client = httpx.Client(
36
+ base_url=self._base_url,
37
+ headers={
38
+ "Authorization": f"Bearer {token}",
39
+ "Accept": "application/json",
40
+ "Content-Type": "application/json",
41
+ },
42
+ timeout=timeout,
43
+ )
44
+ self._project_cache: dict[bool, list[dict[str, Any]]] = {}
45
+
46
+ def __enter__(self) -> TimingApiClient:
47
+ return self
48
+
49
+ def __exit__(self, *exc: object) -> None:
50
+ self.close()
51
+
52
+ def close(self) -> None:
53
+ self._client.close()
54
+
55
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
56
+ try:
57
+ resp = self._client.request(method, path, **kwargs)
58
+ except httpx.HTTPError as exc: # pragma: no cover - network dependent
59
+ raise TimingApiError(f"Request to Timing API failed: {exc}") from exc
60
+ if resp.status_code >= 400:
61
+ raise TimingApiError(
62
+ f"Timing API {method} {path} -> {resp.status_code}: {resp.text[:500]}"
63
+ )
64
+ if not resp.content:
65
+ return None
66
+ return resp.json()
67
+
68
+ # -- Projects ---------------------------------------------------------
69
+
70
+ def list_projects(self, hide_archived: bool = True) -> list[dict[str, Any]]:
71
+ if hide_archived in self._project_cache:
72
+ return self._project_cache[hide_archived]
73
+
74
+ params = {"hide_archived": "true" if hide_archived else "false"}
75
+ data = self._request("GET", "/projects", params=params)
76
+ projects = data.get("data", []) if isinstance(data, dict) else (data or [])
77
+ self._project_cache[hide_archived] = projects
78
+ return projects
79
+
80
+ def find_project_ref(self, title: str) -> str | None:
81
+ """Compatibility wrapper for strict project resolution.
82
+
83
+ Returns the unique Web-API self-reference for ``title`` or ``None`` when
84
+ no project matches. Raises ``TimingApiError`` for ambiguous leaf-title
85
+ matches; callers that need disambiguation should use
86
+ ``resolve_project_ref`` with a title chain or config overrides.
87
+ """
88
+ return self.resolve_project_ref(title)
89
+
90
+ def resolve_project_ref(
91
+ self,
92
+ title: str,
93
+ title_chain: list[str] | tuple[str, ...] | None = None,
94
+ project_id: int | None = None,
95
+ overrides: dict[str, str] | None = None,
96
+ ) -> str | None:
97
+ """Resolve a local project to a unique Web-API self-reference.
98
+
99
+ Resolution order:
100
+ 1. Config overrides keyed by local id, ``id:<id>``, full title chain, or
101
+ leaf title.
102
+ 2. Exact remote title-chain match.
103
+ 3. Exact remote leaf-title match, only when unique.
104
+ """
105
+ chain = [part.strip() for part in title_chain or [] if part.strip()]
106
+ if not chain and title.strip():
107
+ chain = [title.strip()]
108
+ full_chain = " / ".join(chain)
109
+
110
+ override_keys = []
111
+ if project_id is not None:
112
+ override_keys.extend((str(project_id), f"id:{project_id}"))
113
+ override_keys.extend(key for key in (full_chain, title.strip()) if key)
114
+
115
+ normalized_overrides = {
116
+ key.strip().lower(): value for key, value in (overrides or {}).items()
117
+ }
118
+ for key in override_keys:
119
+ ref = normalized_overrides.get(key.strip().lower())
120
+ if ref:
121
+ return ref
122
+
123
+ target = title.strip().lower()
124
+ projects = self.list_projects(hide_archived=False)
125
+
126
+ if full_chain:
127
+ chain_matches = [
128
+ project
129
+ for project in projects
130
+ if _project_full_title(project).lower() == full_chain.lower()
131
+ ]
132
+ if len(chain_matches) == 1:
133
+ return _project_ref(chain_matches[0])
134
+ if len(chain_matches) > 1:
135
+ raise TimingApiError(_ambiguous_project_message(full_chain, chain_matches))
136
+
137
+ leaf_matches = [
138
+ project
139
+ for project in projects
140
+ if _project_leaf_title(project).lower() == target
141
+ ]
142
+ if len(leaf_matches) == 1:
143
+ return _project_ref(leaf_matches[0])
144
+ if len(leaf_matches) > 1:
145
+ raise TimingApiError(_ambiguous_project_message(title, leaf_matches))
146
+ return None
147
+
148
+ def create_project(self, title: str, parent_ref: str | None = None) -> dict[str, Any]:
149
+ body: dict[str, Any] = {"title": title}
150
+ if parent_ref:
151
+ body["parent"] = parent_ref
152
+ data = self._request("POST", "/projects", json=body)
153
+ return data.get("data", data) if isinstance(data, dict) else data
154
+
155
+ # -- Time entries -----------------------------------------------------
156
+
157
+ def list_time_entries(
158
+ self,
159
+ start_min: datetime | None = None,
160
+ start_max: datetime | None = None,
161
+ ) -> list[dict[str, Any]]:
162
+ params: dict[str, str] = {}
163
+ if start_min:
164
+ params["start_date_min"] = start_min.isoformat()
165
+ if start_max:
166
+ params["start_date_max"] = start_max.isoformat()
167
+ data = self._request("GET", "/time-entries", params=params)
168
+ return data.get("data", []) if isinstance(data, dict) else (data or [])
169
+
170
+ def create_time_entry(
171
+ self,
172
+ start: datetime,
173
+ end: datetime,
174
+ project_ref: str | None,
175
+ title: str,
176
+ notes: str = "",
177
+ replace_existing: bool = False,
178
+ ) -> dict[str, Any]:
179
+ body: dict[str, Any] = {
180
+ "start_date": start.isoformat(),
181
+ "end_date": end.isoformat(),
182
+ "title": title,
183
+ "replace_existing": replace_existing,
184
+ }
185
+ if project_ref:
186
+ body["project"] = project_ref
187
+ if notes:
188
+ body["notes"] = notes
189
+ data = self._request("POST", "/time-entries", json=body)
190
+ return data.get("data", data) if isinstance(data, dict) else data
191
+
192
+ def has_matching_time_entry(
193
+ self,
194
+ entries: list[dict[str, Any]],
195
+ start: datetime,
196
+ end: datetime,
197
+ title: str,
198
+ project_ref: str | None,
199
+ ) -> bool:
200
+ """Return True if an existing entry already represents this suggestion.
201
+
202
+ This is an idempotency heuristic for ``push``. It intentionally requires
203
+ matching start/end timestamps, title, and project reference; if Timing's
204
+ API later normalizes titles or rounds timestamps differently, callers
205
+ should prefer ``--replace`` or broaden this matcher deliberately.
206
+ """
207
+ return any(
208
+ _time_entry_matches(entry, start, end, title, project_ref)
209
+ for entry in entries
210
+ )
211
+
212
+ def generate_report(
213
+ self,
214
+ start_min: datetime,
215
+ start_max: datetime,
216
+ project_refs: list[str] | None = None,
217
+ columns: list[str] | None = None,
218
+ include_app_usage: bool = False,
219
+ ) -> Any:
220
+ params: list[tuple[str, str]] = [
221
+ ("start_date_min", start_min.isoformat()),
222
+ ("start_date_max", start_max.isoformat()),
223
+ ]
224
+ for ref in project_refs or []:
225
+ params.append(("project_ids[]", ref))
226
+ for col in columns or []:
227
+ params.append(("columns[]", col))
228
+ if include_app_usage:
229
+ params.append(("include_app_usage", "true"))
230
+ return self._request("GET", "/report", params=params)
231
+
232
+
233
+ def _project_chain(project: dict[str, Any]) -> list[str]:
234
+ chain = project.get("title_chain") or []
235
+ if isinstance(chain, list) and chain:
236
+ return [str(part) for part in chain]
237
+ title = project.get("title")
238
+ return [str(title)] if title else []
239
+
240
+
241
+ def _project_full_title(project: dict[str, Any]) -> str:
242
+ return " / ".join(_project_chain(project)).strip()
243
+
244
+
245
+ def _project_leaf_title(project: dict[str, Any]) -> str:
246
+ chain = _project_chain(project)
247
+ if chain:
248
+ return chain[-1].strip()
249
+ return str(project.get("title") or "").strip()
250
+
251
+
252
+ def _project_ref(project: dict[str, Any]) -> str | None:
253
+ ref = project.get("self") or project.get("url")
254
+ if ref is not None:
255
+ return str(ref)
256
+ project_id = project.get("id")
257
+ if project_id is None:
258
+ return None
259
+ project_ref = str(project_id)
260
+ return project_ref if project_ref.startswith("/") else f"/projects/{project_ref}"
261
+
262
+
263
+ def _ambiguous_project_message(title: str, projects: list[dict[str, Any]]) -> str:
264
+ labels = ", ".join(
265
+ f"{_project_full_title(project) or _project_leaf_title(project)} ({_project_ref(project)})"
266
+ for project in projects
267
+ )
268
+ return (
269
+ f"Timing project '{title}' is ambiguous: {labels}. "
270
+ "Add an explicit [project_mappings] entry in the config."
271
+ )
272
+
273
+
274
+ def _parse_api_datetime(value: Any) -> datetime | None:
275
+ if not isinstance(value, str):
276
+ return None
277
+ text = value.strip()
278
+ if text.endswith("Z"):
279
+ text = f"{text[:-1]}+00:00"
280
+ try:
281
+ return datetime.fromisoformat(text).astimezone()
282
+ except ValueError:
283
+ return None
284
+
285
+
286
+ def _entry_project_ref(entry: dict[str, Any]) -> str | None:
287
+ project = entry.get("project")
288
+ if isinstance(project, str):
289
+ return project
290
+ if isinstance(project, dict):
291
+ return _project_ref(project)
292
+ return None
293
+
294
+
295
+ def _time_entry_matches(
296
+ entry: dict[str, Any],
297
+ start: datetime,
298
+ end: datetime,
299
+ title: str,
300
+ project_ref: str | None,
301
+ ) -> bool:
302
+ entry_start = _parse_api_datetime(entry.get("start_date") or entry.get("startDate"))
303
+ entry_end = _parse_api_datetime(entry.get("end_date") or entry.get("endDate"))
304
+ if entry_start is None or entry_end is None:
305
+ return False
306
+
307
+ same_start = abs((entry_start - start).total_seconds()) < 1
308
+ same_end = abs((entry_end - end).total_seconds()) < 1
309
+ same_title = (entry.get("title") or "") == title
310
+ same_project = _entry_project_ref(entry) == project_ref
311
+ return same_start and same_end and same_title and same_project