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/db.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Read-only access to the local Timing.app SQLite database.
|
|
2
|
+
|
|
3
|
+
Timing stores its automatically recorded app activity in a Core-Data SQLite
|
|
4
|
+
store (``SQLite.db``). We open it strictly read-only so we never interfere with
|
|
5
|
+
Timing's own writes or its sync engine.
|
|
6
|
+
|
|
7
|
+
Key schema facts (Timing2):
|
|
8
|
+
* ``AppActivity(startDate, endDate, applicationID, titleID, pathID, projectID,
|
|
9
|
+
isDeleted)`` — one row per automatically tracked activity slice.
|
|
10
|
+
* ``startDate`` / ``endDate`` are **Unix epoch seconds** (REAL), NOT Core-Data
|
|
11
|
+
reference dates. Verified empirically: adding the 978307200 NSDate offset
|
|
12
|
+
shifts timestamps ~31 years into the future.
|
|
13
|
+
* ``Application(bundleIdentifier, executable, title)``, ``Title(stringValue)``
|
|
14
|
+
and ``Path(stringValue)`` are normalized lookup tables.
|
|
15
|
+
* ``Project(id, title, parentID, color, productivityScore, isArchived)``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import sqlite3
|
|
21
|
+
from collections.abc import Iterator
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from timing_cli.models import AppUsage, Project
|
|
27
|
+
from timing_cli.timing_predicates import TimingPredicateRule, decode_timing_predicate
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TimingDatabaseError(RuntimeError):
|
|
31
|
+
"""Raised when the local Timing database cannot be opened or read."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _epoch_to_local(value: float) -> datetime:
|
|
35
|
+
"""Convert a Timing Unix-epoch timestamp to an aware local datetime."""
|
|
36
|
+
return datetime.fromtimestamp(value).astimezone()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@contextmanager
|
|
40
|
+
def open_db(db_path: Path) -> Iterator[sqlite3.Connection]:
|
|
41
|
+
"""Open the Timing database read-only.
|
|
42
|
+
|
|
43
|
+
We deliberately do NOT copy the database. A read-only URI connection reads
|
|
44
|
+
the live WAL without taking a write lock, so Timing keeps running normally.
|
|
45
|
+
"""
|
|
46
|
+
if not db_path.exists():
|
|
47
|
+
raise TimingDatabaseError(
|
|
48
|
+
f"Timing database not found at {db_path}. Is Timing.app installed? "
|
|
49
|
+
"Set db_path in ~/.config/timing-cli/config.toml if it lives elsewhere."
|
|
50
|
+
)
|
|
51
|
+
uri = f"file:{db_path}?mode=ro"
|
|
52
|
+
try:
|
|
53
|
+
conn = sqlite3.connect(uri, uri=True, timeout=5.0)
|
|
54
|
+
except sqlite3.OperationalError as exc: # pragma: no cover - environment specific
|
|
55
|
+
raise TimingDatabaseError(f"Could not open Timing database read-only: {exc}") from exc
|
|
56
|
+
conn.row_factory = sqlite3.Row
|
|
57
|
+
try:
|
|
58
|
+
yield conn
|
|
59
|
+
finally:
|
|
60
|
+
conn.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def list_projects(conn: sqlite3.Connection, include_archived: bool = True) -> list[Project]:
|
|
64
|
+
"""Return all local projects, ordered by hierarchy position."""
|
|
65
|
+
if include_archived:
|
|
66
|
+
query = """
|
|
67
|
+
SELECT id, title, parentID, isArchived, color, productivityScore
|
|
68
|
+
FROM Project
|
|
69
|
+
ORDER BY parentID IS NOT NULL, listPosition
|
|
70
|
+
"""
|
|
71
|
+
else:
|
|
72
|
+
query = """
|
|
73
|
+
SELECT id, title, parentID, isArchived, color, productivityScore
|
|
74
|
+
FROM Project
|
|
75
|
+
WHERE isArchived = 0
|
|
76
|
+
ORDER BY parentID IS NOT NULL, listPosition
|
|
77
|
+
"""
|
|
78
|
+
rows = conn.execute(query).fetchall()
|
|
79
|
+
return [
|
|
80
|
+
Project(
|
|
81
|
+
id=r["id"],
|
|
82
|
+
title=r["title"],
|
|
83
|
+
parent_id=r["parentID"],
|
|
84
|
+
is_archived=bool(r["isArchived"]),
|
|
85
|
+
color=r["color"],
|
|
86
|
+
productivity_score=r["productivityScore"] or 0.0,
|
|
87
|
+
)
|
|
88
|
+
for r in rows
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _project_title_chains(
|
|
93
|
+
conn: sqlite3.Connection,
|
|
94
|
+
project_ids: set[int],
|
|
95
|
+
) -> dict[int, list[str]]:
|
|
96
|
+
"""Return local project title chains keyed by local project id."""
|
|
97
|
+
projects: dict[int, tuple[str, int | None]] = {}
|
|
98
|
+
|
|
99
|
+
def fetch(project_id: int) -> tuple[str, int | None] | None:
|
|
100
|
+
if project_id in projects:
|
|
101
|
+
return projects[project_id]
|
|
102
|
+
|
|
103
|
+
row = conn.execute(
|
|
104
|
+
"SELECT title, parentID FROM Project WHERE id = ?",
|
|
105
|
+
(project_id,),
|
|
106
|
+
).fetchone()
|
|
107
|
+
if row is None:
|
|
108
|
+
return None
|
|
109
|
+
project = (row["title"], row["parentID"])
|
|
110
|
+
projects[project_id] = project
|
|
111
|
+
return project
|
|
112
|
+
|
|
113
|
+
chains: dict[int, list[str]] = {}
|
|
114
|
+
|
|
115
|
+
def build(project_id: int) -> list[str]:
|
|
116
|
+
if project_id in chains:
|
|
117
|
+
return chains[project_id]
|
|
118
|
+
|
|
119
|
+
seen: set[int] = set()
|
|
120
|
+
chain: list[str] = []
|
|
121
|
+
current: int | None = project_id
|
|
122
|
+
while current is not None and current not in seen:
|
|
123
|
+
seen.add(current)
|
|
124
|
+
project = fetch(current)
|
|
125
|
+
if project is None:
|
|
126
|
+
break
|
|
127
|
+
title, parent_id = project
|
|
128
|
+
chain.append(title)
|
|
129
|
+
current = parent_id
|
|
130
|
+
|
|
131
|
+
chain.reverse()
|
|
132
|
+
chains[project_id] = chain
|
|
133
|
+
return chain
|
|
134
|
+
|
|
135
|
+
return {project_id: build(project_id) for project_id in project_ids}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_app_usage(
|
|
139
|
+
conn: sqlite3.Connection,
|
|
140
|
+
start: datetime,
|
|
141
|
+
end: datetime,
|
|
142
|
+
project_id: int | None = None,
|
|
143
|
+
) -> list[AppUsage]:
|
|
144
|
+
"""Return automatically tracked app usage overlapping ``[start, end)``.
|
|
145
|
+
|
|
146
|
+
A slice is included when it overlaps the window at all (its start is before
|
|
147
|
+
``end`` and its end is after ``start``).
|
|
148
|
+
"""
|
|
149
|
+
if end <= start:
|
|
150
|
+
return []
|
|
151
|
+
|
|
152
|
+
params: list[float | int] = [end.timestamp(), start.timestamp()]
|
|
153
|
+
if project_id is None:
|
|
154
|
+
query = """
|
|
155
|
+
SELECT
|
|
156
|
+
a.id AS id,
|
|
157
|
+
a.startDate AS start_ts,
|
|
158
|
+
a.endDate AS end_ts,
|
|
159
|
+
a.applicationID AS application_id,
|
|
160
|
+
a.projectID AS project_id,
|
|
161
|
+
p.title AS project_title,
|
|
162
|
+
app.title AS app_title,
|
|
163
|
+
app.bundleIdentifier AS bundle_id,
|
|
164
|
+
app.executable AS executable,
|
|
165
|
+
t.stringValue AS window_title,
|
|
166
|
+
pa.stringValue AS doc_path
|
|
167
|
+
FROM AppActivity a
|
|
168
|
+
JOIN Application app ON app.id = a.applicationID
|
|
169
|
+
LEFT JOIN Title t ON t.id = a.titleID
|
|
170
|
+
LEFT JOIN Path pa ON pa.id = a.pathID
|
|
171
|
+
LEFT JOIN Project p ON p.id = a.projectID
|
|
172
|
+
WHERE a.isDeleted = 0
|
|
173
|
+
AND a.startDate < ?
|
|
174
|
+
AND a.endDate > ?
|
|
175
|
+
ORDER BY a.startDate
|
|
176
|
+
"""
|
|
177
|
+
else:
|
|
178
|
+
query = """
|
|
179
|
+
SELECT
|
|
180
|
+
a.id AS id,
|
|
181
|
+
a.startDate AS start_ts,
|
|
182
|
+
a.endDate AS end_ts,
|
|
183
|
+
a.applicationID AS application_id,
|
|
184
|
+
a.projectID AS project_id,
|
|
185
|
+
p.title AS project_title,
|
|
186
|
+
app.title AS app_title,
|
|
187
|
+
app.bundleIdentifier AS bundle_id,
|
|
188
|
+
app.executable AS executable,
|
|
189
|
+
t.stringValue AS window_title,
|
|
190
|
+
pa.stringValue AS doc_path
|
|
191
|
+
FROM AppActivity a
|
|
192
|
+
JOIN Application app ON app.id = a.applicationID
|
|
193
|
+
LEFT JOIN Title t ON t.id = a.titleID
|
|
194
|
+
LEFT JOIN Path pa ON pa.id = a.pathID
|
|
195
|
+
LEFT JOIN Project p ON p.id = a.projectID
|
|
196
|
+
WHERE a.isDeleted = 0
|
|
197
|
+
AND a.startDate < ?
|
|
198
|
+
AND a.endDate > ?
|
|
199
|
+
AND a.projectID = ?
|
|
200
|
+
ORDER BY a.startDate
|
|
201
|
+
"""
|
|
202
|
+
params.append(project_id)
|
|
203
|
+
|
|
204
|
+
rows = conn.execute(query, params).fetchall()
|
|
205
|
+
|
|
206
|
+
project_ids = {r["project_id"] for r in rows if r["project_id"] is not None}
|
|
207
|
+
title_chains = _project_title_chains(conn, project_ids)
|
|
208
|
+
usage: list[AppUsage] = []
|
|
209
|
+
for r in rows:
|
|
210
|
+
app_name = r["app_title"] or r["bundle_id"] or r["executable"] or "Unknown"
|
|
211
|
+
clipped_start = max(_epoch_to_local(r["start_ts"]), start)
|
|
212
|
+
clipped_end = min(_epoch_to_local(r["end_ts"]), end)
|
|
213
|
+
if clipped_end <= clipped_start:
|
|
214
|
+
continue
|
|
215
|
+
project_title_chain = title_chains.get(r["project_id"], [])
|
|
216
|
+
project_title = r["project_title"]
|
|
217
|
+
if project_title and not project_title_chain:
|
|
218
|
+
project_title_chain = [project_title]
|
|
219
|
+
usage.append(
|
|
220
|
+
AppUsage(
|
|
221
|
+
id=r["id"],
|
|
222
|
+
start=clipped_start,
|
|
223
|
+
end=clipped_end,
|
|
224
|
+
application_id=r["application_id"],
|
|
225
|
+
app=app_name,
|
|
226
|
+
bundle_id=r["bundle_id"],
|
|
227
|
+
title=r["window_title"],
|
|
228
|
+
path=r["doc_path"],
|
|
229
|
+
project_id=r["project_id"],
|
|
230
|
+
project_title=project_title,
|
|
231
|
+
project_title_chain=project_title_chain,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
return usage
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def list_timing_predicate_rules(
|
|
238
|
+
conn: sqlite3.Connection,
|
|
239
|
+
include_archived: bool = False,
|
|
240
|
+
) -> list[TimingPredicateRule]:
|
|
241
|
+
"""Return decoded Timing project predicate rules from the local database."""
|
|
242
|
+
project_columns = {r["name"] for r in conn.execute("PRAGMA table_info(Project)")}
|
|
243
|
+
if "predicate" not in project_columns:
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
if include_archived:
|
|
247
|
+
query = """
|
|
248
|
+
SELECT id, title, predicate
|
|
249
|
+
FROM Project
|
|
250
|
+
WHERE predicate IS NOT NULL
|
|
251
|
+
ORDER BY ruleListPosition, listPosition
|
|
252
|
+
"""
|
|
253
|
+
else:
|
|
254
|
+
query = """
|
|
255
|
+
SELECT id, title, predicate
|
|
256
|
+
FROM Project
|
|
257
|
+
WHERE predicate IS NOT NULL AND isArchived = 0
|
|
258
|
+
ORDER BY ruleListPosition, listPosition
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
rows = conn.execute(query).fetchall()
|
|
262
|
+
title_chains = _project_title_chains(conn, {r["id"] for r in rows})
|
|
263
|
+
rules: list[TimingPredicateRule] = []
|
|
264
|
+
for row in rows:
|
|
265
|
+
conditions = decode_timing_predicate(row["predicate"])
|
|
266
|
+
if not conditions:
|
|
267
|
+
continue
|
|
268
|
+
title_chain = tuple(title_chains.get(row["id"], [row["title"]]))
|
|
269
|
+
rules.append(
|
|
270
|
+
TimingPredicateRule(
|
|
271
|
+
project_id=row["id"],
|
|
272
|
+
project_title=row["title"],
|
|
273
|
+
project_title_chain=title_chain,
|
|
274
|
+
conditions=conditions,
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
return rules
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def date_range(conn: sqlite3.Connection) -> tuple[datetime, datetime] | None:
|
|
281
|
+
"""Return the (earliest start, latest end) of recorded activity, or None."""
|
|
282
|
+
row = conn.execute(
|
|
283
|
+
"SELECT MIN(startDate) AS lo, MAX(endDate) AS hi FROM AppActivity WHERE isDeleted = 0"
|
|
284
|
+
).fetchone()
|
|
285
|
+
if row is None or row["lo"] is None:
|
|
286
|
+
return None
|
|
287
|
+
return _epoch_to_local(row["lo"]), _epoch_to_local(row["hi"])
|
timing_cli/models.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pydantic models shared across the CLI, DB layer, analysis and MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Project(BaseModel):
|
|
11
|
+
"""A Timing project, as read from the local database."""
|
|
12
|
+
|
|
13
|
+
id: int
|
|
14
|
+
title: str
|
|
15
|
+
parent_id: int | None = None
|
|
16
|
+
is_archived: bool = False
|
|
17
|
+
color: str | None = None
|
|
18
|
+
productivity_score: float = 0.0
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def api_ref(self) -> str:
|
|
22
|
+
"""Self-reference used by the Timing Web API (e.g. ``/projects/1``).
|
|
23
|
+
|
|
24
|
+
NOTE: the local database ``id`` and the Web-API project id are NOT the
|
|
25
|
+
same namespace. This helper only builds the API path shape; callers that
|
|
26
|
+
push to the API must map local titles to remote project ids first (see
|
|
27
|
+
``timing_cli.api.TimingApiClient.resolve_project_ref``).
|
|
28
|
+
"""
|
|
29
|
+
return f"/projects/{self.id}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AppUsage(BaseModel):
|
|
33
|
+
"""A single automatically recorded app-activity slice from the local DB."""
|
|
34
|
+
|
|
35
|
+
id: int
|
|
36
|
+
start: datetime
|
|
37
|
+
end: datetime
|
|
38
|
+
application_id: int | None = None
|
|
39
|
+
app: str = Field(description="Human-readable app name or bundle identifier")
|
|
40
|
+
bundle_id: str | None = None
|
|
41
|
+
title: str | None = Field(default=None, description="Window title")
|
|
42
|
+
path: str | None = Field(default=None, description="Document / file path")
|
|
43
|
+
project_id: int | None = Field(default=None, description="Local Timing project id, if assigned")
|
|
44
|
+
project_title: str | None = None
|
|
45
|
+
project_title_chain: list[str] = Field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def duration_seconds(self) -> float:
|
|
49
|
+
return (self.end - self.start).total_seconds()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TimeEntrySuggestion(BaseModel):
|
|
53
|
+
"""An aggregated block of app usage, ready to become a Timing time entry."""
|
|
54
|
+
|
|
55
|
+
day: str = Field(description="ISO date (YYYY-MM-DD, local) the block belongs to")
|
|
56
|
+
start: datetime
|
|
57
|
+
end: datetime
|
|
58
|
+
project_id: int | None = None
|
|
59
|
+
project_title: str = "Unassigned"
|
|
60
|
+
project_title_chain: list[str] = Field(default_factory=list)
|
|
61
|
+
title: str = ""
|
|
62
|
+
notes: str = ""
|
|
63
|
+
source_count: int = 0
|
|
64
|
+
top_apps: list[str] = Field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def duration_seconds(self) -> float:
|
|
68
|
+
return (self.end - self.start).total_seconds()
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def duration_minutes(self) -> float:
|
|
72
|
+
return self.duration_seconds / 60.0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ProjectSummary(BaseModel):
|
|
76
|
+
"""Aggregated time per project for a reporting window."""
|
|
77
|
+
|
|
78
|
+
project_id: int | None = None
|
|
79
|
+
project_title: str = "Unassigned"
|
|
80
|
+
seconds: float = 0.0
|
|
81
|
+
entries: int = 0
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def minutes(self) -> float:
|
|
85
|
+
return self.seconds / 60.0
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def hours(self) -> float:
|
|
89
|
+
return self.seconds / 3600.0
|
timing_cli/output.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Rich-based rendering helpers for the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
from timing_cli.models import AppUsage, ProjectSummary, TimeEntrySuggestion
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
err_console = Console(stderr=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fmt_duration(seconds: float) -> str:
|
|
15
|
+
total = int(round(seconds))
|
|
16
|
+
hours, rem = divmod(total, 3600)
|
|
17
|
+
minutes, secs = divmod(rem, 60)
|
|
18
|
+
if hours:
|
|
19
|
+
return f"{hours}h {minutes:02d}m"
|
|
20
|
+
if minutes:
|
|
21
|
+
return f"{minutes}m {secs:02d}s"
|
|
22
|
+
return f"{secs}s"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def render_usage(usage: list[AppUsage]) -> None:
|
|
26
|
+
table = Table(title="App usage", show_lines=False)
|
|
27
|
+
table.add_column("Start", style="cyan", no_wrap=True)
|
|
28
|
+
table.add_column("Dur", justify="right")
|
|
29
|
+
table.add_column("App", style="green")
|
|
30
|
+
table.add_column("Title")
|
|
31
|
+
table.add_column("Project", style="magenta")
|
|
32
|
+
for u in usage:
|
|
33
|
+
table.add_row(
|
|
34
|
+
u.start.strftime("%H:%M:%S"),
|
|
35
|
+
_fmt_duration(u.duration_seconds),
|
|
36
|
+
u.app,
|
|
37
|
+
(u.title or "")[:50],
|
|
38
|
+
u.project_title or "",
|
|
39
|
+
)
|
|
40
|
+
console.print(table)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render_summary(summaries: list[ProjectSummary], title: str = "Project summary") -> None:
|
|
44
|
+
table = Table(title=title)
|
|
45
|
+
table.add_column("Project", style="magenta")
|
|
46
|
+
table.add_column("Time", justify="right")
|
|
47
|
+
table.add_column("Slices", justify="right", style="dim")
|
|
48
|
+
total = 0.0
|
|
49
|
+
for s in summaries:
|
|
50
|
+
table.add_row(s.project_title, _fmt_duration(s.seconds), str(s.entries))
|
|
51
|
+
total += s.seconds
|
|
52
|
+
table.add_section()
|
|
53
|
+
table.add_row("[bold]Total[/bold]", f"[bold]{_fmt_duration(total)}[/bold]", "")
|
|
54
|
+
console.print(table)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_suggestions(suggestions: list[TimeEntrySuggestion]) -> None:
|
|
58
|
+
table = Table(title="Suggested time entries")
|
|
59
|
+
table.add_column("Day", style="cyan", no_wrap=True)
|
|
60
|
+
table.add_column("Start", no_wrap=True)
|
|
61
|
+
table.add_column("End", no_wrap=True)
|
|
62
|
+
table.add_column("Dur", justify="right")
|
|
63
|
+
table.add_column("Project", style="magenta")
|
|
64
|
+
table.add_column("Title")
|
|
65
|
+
total = 0.0
|
|
66
|
+
for s in suggestions:
|
|
67
|
+
table.add_row(
|
|
68
|
+
s.day,
|
|
69
|
+
s.start.strftime("%H:%M"),
|
|
70
|
+
s.end.strftime("%H:%M"),
|
|
71
|
+
_fmt_duration(s.duration_seconds),
|
|
72
|
+
s.project_title,
|
|
73
|
+
(s.title or "")[:50],
|
|
74
|
+
)
|
|
75
|
+
total += s.duration_seconds
|
|
76
|
+
table.add_section()
|
|
77
|
+
table.add_row("", "", "", f"[bold]{_fmt_duration(total)}[/bold]", "", "")
|
|
78
|
+
console.print(table)
|
timing_cli/rules.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Classify app usage onto projects.
|
|
2
|
+
|
|
3
|
+
Only ~15% of raw app activity is auto-assigned to a project by Timing's own
|
|
4
|
+
predicate rules. To generate useful time entries we layer our own rules on top:
|
|
5
|
+
|
|
6
|
+
1. If Timing already assigned a project to the slice, keep it.
|
|
7
|
+
2. Otherwise, apply the user's configured rules (first match wins).
|
|
8
|
+
3. Otherwise, the slice is left "Unassigned".
|
|
9
|
+
|
|
10
|
+
Rules match on app name / bundle id (case-insensitive substring) and on window
|
|
11
|
+
title / document path (regex). See ``timing_cli.config.Rule``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
from timing_cli.config import Rule
|
|
20
|
+
from timing_cli.models import AppUsage
|
|
21
|
+
from timing_cli.timing_predicates import TimingPredicateRule
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Classification:
|
|
26
|
+
"""The project a slice was classified into and where the decision came from."""
|
|
27
|
+
|
|
28
|
+
project_title: str
|
|
29
|
+
project_id: int | None
|
|
30
|
+
source: str # "timing" | "rule" | "unassigned"
|
|
31
|
+
project_title_chain: tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
UNASSIGNED = "Unassigned"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _CompiledRule:
|
|
38
|
+
__slots__ = ("rule", "_title_re", "_path_re")
|
|
39
|
+
|
|
40
|
+
def __init__(self, rule: Rule) -> None:
|
|
41
|
+
self.rule = rule
|
|
42
|
+
self._title_re = re.compile(rule.title, re.IGNORECASE) if rule.title else None
|
|
43
|
+
self._path_re = re.compile(rule.path, re.IGNORECASE) if rule.path else None
|
|
44
|
+
|
|
45
|
+
def matches(self, usage: AppUsage) -> bool:
|
|
46
|
+
r = self.rule
|
|
47
|
+
if r.app and r.app.lower() not in (usage.app or "").lower():
|
|
48
|
+
return False
|
|
49
|
+
if r.bundle_id and r.bundle_id.lower() not in (usage.bundle_id or "").lower():
|
|
50
|
+
return False
|
|
51
|
+
if self._title_re and not self._title_re.search(usage.title or ""):
|
|
52
|
+
return False
|
|
53
|
+
if self._path_re and not self._path_re.search(usage.path or ""):
|
|
54
|
+
return False
|
|
55
|
+
# A rule with no criteria at all should never match everything.
|
|
56
|
+
return any((r.app, r.bundle_id, r.title, r.path))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Classifier:
|
|
60
|
+
"""Apply project classification rules.
|
|
61
|
+
|
|
62
|
+
Order is deliberate: already-assigned Timing slices win, then explicit user
|
|
63
|
+
config rules, then decoded Timing project predicate rules, then Unassigned.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
rules: list[Rule],
|
|
69
|
+
timing_rules: list[TimingPredicateRule] | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._compiled = [_CompiledRule(r) for r in rules]
|
|
72
|
+
self._timing_rules = timing_rules or []
|
|
73
|
+
|
|
74
|
+
def classify(self, usage: AppUsage) -> Classification:
|
|
75
|
+
# 1. Trust Timing's own project assignment when present.
|
|
76
|
+
if usage.project_id is not None and usage.project_title:
|
|
77
|
+
chain = tuple(usage.project_title_chain or [usage.project_title])
|
|
78
|
+
return Classification(usage.project_title, usage.project_id, "timing", chain)
|
|
79
|
+
|
|
80
|
+
# 2. First matching user rule wins.
|
|
81
|
+
for compiled in self._compiled:
|
|
82
|
+
if compiled.matches(usage):
|
|
83
|
+
return Classification(
|
|
84
|
+
compiled.rule.project,
|
|
85
|
+
None,
|
|
86
|
+
"rule",
|
|
87
|
+
(compiled.rule.project,),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# 3. Reuse Timing's own project predicate rules from the local DB.
|
|
91
|
+
for timing_rule in self._timing_rules:
|
|
92
|
+
if timing_rule.matches(usage):
|
|
93
|
+
return Classification(
|
|
94
|
+
timing_rule.project_title,
|
|
95
|
+
timing_rule.project_id,
|
|
96
|
+
"timing_predicate",
|
|
97
|
+
timing_rule.project_title_chain,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# 4. Fall back to unassigned.
|
|
101
|
+
return Classification(UNASSIGNED, None, "unassigned", (UNASSIGNED,))
|