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/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,))