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 +3 -0
- timing_cli/analysis.py +161 -0
- timing_cli/api.py +311 -0
- timing_cli/cli.py +348 -0
- timing_cli/config.py +92 -0
- timing_cli/db.py +287 -0
- timing_cli/models.py +89 -0
- timing_cli/output.py +78 -0
- timing_cli/rules.py +101 -0
- timing_cli/serve.py +207 -0
- timing_cli/timing_predicates.py +229 -0
- timing_cli-0.1.0.dist-info/METADATA +218 -0
- timing_cli-0.1.0.dist-info/RECORD +16 -0
- timing_cli-0.1.0.dist-info/WHEEL +4 -0
- timing_cli-0.1.0.dist-info/entry_points.txt +2 -0
- timing_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
timing_cli/__init__.py
ADDED
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
|