daytrace 0.1.0__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.
- daytrace-0.1.0/LICENSE +21 -0
- daytrace-0.1.0/PKG-INFO +129 -0
- daytrace-0.1.0/README.md +117 -0
- daytrace-0.1.0/pyproject.toml +27 -0
- daytrace-0.1.0/pyproject.toml.orig +27 -0
- daytrace-0.1.0/src/daytrace/__init__.py +1 -0
- daytrace-0.1.0/src/daytrace/__main__.py +3 -0
- daytrace-0.1.0/src/daytrace/activitywatch.py +55 -0
- daytrace-0.1.0/src/daytrace/cli.py +76 -0
- daytrace-0.1.0/src/daytrace/markdown.py +95 -0
- daytrace-0.1.0/src/daytrace/models.py +103 -0
- daytrace-0.1.0/src/daytrace/normalize.py +76 -0
- daytrace-0.1.0/src/daytrace/report.py +54 -0
- daytrace-0.1.0/src/daytrace/source.py +113 -0
- daytrace-0.1.0/src/daytrace/time.py +16 -0
- daytrace-0.1.0/src/daytrace/transform.py +123 -0
daytrace-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daytrace contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
daytrace-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: daytrace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Deterministic daily summaries from ActivityWatch
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: aw-client>=0.5.15,<0.6
|
|
8
|
+
Requires-Dist: tzlocal>=5.4,<6
|
|
9
|
+
Requires-Dist: tzdata>=2026.3 ; sys_platform == 'win32'
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Daytrace
|
|
14
|
+
|
|
15
|
+
Daytrace is a privacy-first desktop utility that turns a person's day into a
|
|
16
|
+
searchable text journal. Screen, microphone, keyboard activity, and system
|
|
17
|
+
context are independent opt-ins. Captured media is processed ephemerally and
|
|
18
|
+
discarded; the durable record contains text and structured metadata only.
|
|
19
|
+
|
|
20
|
+
This repository now includes a headless ActivityWatch summary prototype. The
|
|
21
|
+
broader cross-platform capture application remains in the product and
|
|
22
|
+
architecture planning phase.
|
|
23
|
+
|
|
24
|
+
## ActivityWatch summary prototype
|
|
25
|
+
|
|
26
|
+
The first executable prototype is a Python CLI that reads a selected day from
|
|
27
|
+
an already-running ActivityWatch instance and produces deterministic Markdown.
|
|
28
|
+
It does not copy raw events or call an LLM.
|
|
29
|
+
|
|
30
|
+
Prerequisites: install and run ActivityWatch, then install `uv`. On Windows:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
winget install --id=astral-sh.uv -e
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
From a source checkout:
|
|
37
|
+
|
|
38
|
+
```powershell
|
|
39
|
+
git switch codex/activitywatch-integration-research
|
|
40
|
+
uv sync
|
|
41
|
+
uv run daytrace activitywatch --date 2026-09-10 --output summary.md
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Filter the report to records containing a project name:
|
|
45
|
+
|
|
46
|
+
```powershell
|
|
47
|
+
uv run daytrace activitywatch --date 2026-09-10 --project daytrace --output summary.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
The resulting `summary.md` contains an overview, a chronological timeline, and
|
|
51
|
+
application totals. A day with no matching activity is still a successful
|
|
52
|
+
report and contains `No matching activity.`
|
|
53
|
+
|
|
54
|
+
After the package is published, the equivalent one-off command will be:
|
|
55
|
+
|
|
56
|
+
```powershell
|
|
57
|
+
uvx daytrace activitywatch --date 2026-09-10 --output summary.md
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Python callers—including a future second-brain integration—can use the same
|
|
61
|
+
deterministic renderer directly:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from datetime import date
|
|
65
|
+
|
|
66
|
+
from daytrace.activitywatch import summarize_day
|
|
67
|
+
|
|
68
|
+
markdown = summarize_day(date(2026, 9, 10), project="daytrace")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Daytrace reads ActivityWatch through `http://127.0.0.1:5600` by default. It
|
|
72
|
+
does not retain images, audio, video, full browser URLs, or a second copy of
|
|
73
|
+
ActivityWatch events. Use `--server` for another ActivityWatch endpoint and
|
|
74
|
+
`--timezone` for an explicit IANA timezone such as `America/Los_Angeles`.
|
|
75
|
+
|
|
76
|
+
## Product decisions
|
|
77
|
+
|
|
78
|
+
- Local-first: the database and processing stay on the user's computer unless
|
|
79
|
+
the user explicitly configures a cloud provider or export destination.
|
|
80
|
+
- All capture sources start off and require separate, informed consent.
|
|
81
|
+
- Tauri 2 and Rust are the recommended desktop stack, with a small React/Vite
|
|
82
|
+
interface that normally stays hidden behind a tray icon.
|
|
83
|
+
- Accessibility text is preferred over screenshots. OCR is a fallback, and
|
|
84
|
+
image buffers are destroyed after extraction.
|
|
85
|
+
- Microphone audio is held only in bounded memory, segmented with voice
|
|
86
|
+
activity detection, transcribed, and discarded.
|
|
87
|
+
- Keyboard capture defaults to activity signals only, not key content. An
|
|
88
|
+
advanced typed-text mode is a later, separately consented feature with
|
|
89
|
+
password and deny-list suppression.
|
|
90
|
+
- SQLite, B-tree time dimensions, and FTS5 provide the hierarchical timeline
|
|
91
|
+
and full-text search. Captured media and vector embeddings are not stored.
|
|
92
|
+
- The first release supports BYOK and local providers. A hosted inference
|
|
93
|
+
gateway can later fund the project without locking users into the service.
|
|
94
|
+
|
|
95
|
+
## Plan
|
|
96
|
+
|
|
97
|
+
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
|
98
|
+
- [Architecture](docs/ARCHITECTURE.md)
|
|
99
|
+
- [Data model and search](docs/DATA_MODEL.md)
|
|
100
|
+
- [Privacy and security](docs/PRIVACY_SECURITY.md)
|
|
101
|
+
- [Product and UX specification](docs/PRODUCT_UX.md)
|
|
102
|
+
- [Screenpipe reuse audit](docs/SCREENPIPE_REUSE.md)
|
|
103
|
+
- [ActivityWatch integration research](docs/ACTIVITYWATCH_INTEGRATION_RESEARCH.md)
|
|
104
|
+
|
|
105
|
+
## Proposed repository shape
|
|
106
|
+
|
|
107
|
+
```text
|
|
108
|
+
apps/
|
|
109
|
+
desktop/ Tauri window, tray, onboarding, settings
|
|
110
|
+
crates/
|
|
111
|
+
daytrace-core/ orchestration, policies, domain events
|
|
112
|
+
daytrace-capture/ platform-neutral capture traits
|
|
113
|
+
daytrace-platform-*/ macOS, Windows, and Linux adapters
|
|
114
|
+
daytrace-extract/ accessibility, OCR, VAD, transcription
|
|
115
|
+
daytrace-store/ SQLite migrations, FTS, retention
|
|
116
|
+
daytrace-summary/ segmentation and provider-neutral summaries
|
|
117
|
+
daytrace-sync/ deterministic export and GitHub sync
|
|
118
|
+
daytrace-secrets/ OS credential-store abstraction
|
|
119
|
+
extensions/
|
|
120
|
+
browser/ optional Chromium/Firefox tab metadata bridge
|
|
121
|
+
docs/
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Target outcome
|
|
125
|
+
|
|
126
|
+
The v1 release is a signed, auto-updating macOS, Windows, and Linux desktop app
|
|
127
|
+
that can run for an eight-hour day without retaining raw media, recover cleanly
|
|
128
|
+
from sleep and device changes, search a local journal, and create an editable
|
|
129
|
+
end-of-day summary. See the implementation plan for measurable release gates.
|
daytrace-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Daytrace
|
|
2
|
+
|
|
3
|
+
Daytrace is a privacy-first desktop utility that turns a person's day into a
|
|
4
|
+
searchable text journal. Screen, microphone, keyboard activity, and system
|
|
5
|
+
context are independent opt-ins. Captured media is processed ephemerally and
|
|
6
|
+
discarded; the durable record contains text and structured metadata only.
|
|
7
|
+
|
|
8
|
+
This repository now includes a headless ActivityWatch summary prototype. The
|
|
9
|
+
broader cross-platform capture application remains in the product and
|
|
10
|
+
architecture planning phase.
|
|
11
|
+
|
|
12
|
+
## ActivityWatch summary prototype
|
|
13
|
+
|
|
14
|
+
The first executable prototype is a Python CLI that reads a selected day from
|
|
15
|
+
an already-running ActivityWatch instance and produces deterministic Markdown.
|
|
16
|
+
It does not copy raw events or call an LLM.
|
|
17
|
+
|
|
18
|
+
Prerequisites: install and run ActivityWatch, then install `uv`. On Windows:
|
|
19
|
+
|
|
20
|
+
```powershell
|
|
21
|
+
winget install --id=astral-sh.uv -e
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
From a source checkout:
|
|
25
|
+
|
|
26
|
+
```powershell
|
|
27
|
+
git switch codex/activitywatch-integration-research
|
|
28
|
+
uv sync
|
|
29
|
+
uv run daytrace activitywatch --date 2026-09-10 --output summary.md
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Filter the report to records containing a project name:
|
|
33
|
+
|
|
34
|
+
```powershell
|
|
35
|
+
uv run daytrace activitywatch --date 2026-09-10 --project daytrace --output summary.md
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The resulting `summary.md` contains an overview, a chronological timeline, and
|
|
39
|
+
application totals. A day with no matching activity is still a successful
|
|
40
|
+
report and contains `No matching activity.`
|
|
41
|
+
|
|
42
|
+
After the package is published, the equivalent one-off command will be:
|
|
43
|
+
|
|
44
|
+
```powershell
|
|
45
|
+
uvx daytrace activitywatch --date 2026-09-10 --output summary.md
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Python callers—including a future second-brain integration—can use the same
|
|
49
|
+
deterministic renderer directly:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from datetime import date
|
|
53
|
+
|
|
54
|
+
from daytrace.activitywatch import summarize_day
|
|
55
|
+
|
|
56
|
+
markdown = summarize_day(date(2026, 9, 10), project="daytrace")
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Daytrace reads ActivityWatch through `http://127.0.0.1:5600` by default. It
|
|
60
|
+
does not retain images, audio, video, full browser URLs, or a second copy of
|
|
61
|
+
ActivityWatch events. Use `--server` for another ActivityWatch endpoint and
|
|
62
|
+
`--timezone` for an explicit IANA timezone such as `America/Los_Angeles`.
|
|
63
|
+
|
|
64
|
+
## Product decisions
|
|
65
|
+
|
|
66
|
+
- Local-first: the database and processing stay on the user's computer unless
|
|
67
|
+
the user explicitly configures a cloud provider or export destination.
|
|
68
|
+
- All capture sources start off and require separate, informed consent.
|
|
69
|
+
- Tauri 2 and Rust are the recommended desktop stack, with a small React/Vite
|
|
70
|
+
interface that normally stays hidden behind a tray icon.
|
|
71
|
+
- Accessibility text is preferred over screenshots. OCR is a fallback, and
|
|
72
|
+
image buffers are destroyed after extraction.
|
|
73
|
+
- Microphone audio is held only in bounded memory, segmented with voice
|
|
74
|
+
activity detection, transcribed, and discarded.
|
|
75
|
+
- Keyboard capture defaults to activity signals only, not key content. An
|
|
76
|
+
advanced typed-text mode is a later, separately consented feature with
|
|
77
|
+
password and deny-list suppression.
|
|
78
|
+
- SQLite, B-tree time dimensions, and FTS5 provide the hierarchical timeline
|
|
79
|
+
and full-text search. Captured media and vector embeddings are not stored.
|
|
80
|
+
- The first release supports BYOK and local providers. A hosted inference
|
|
81
|
+
gateway can later fund the project without locking users into the service.
|
|
82
|
+
|
|
83
|
+
## Plan
|
|
84
|
+
|
|
85
|
+
- [Implementation plan](docs/IMPLEMENTATION_PLAN.md)
|
|
86
|
+
- [Architecture](docs/ARCHITECTURE.md)
|
|
87
|
+
- [Data model and search](docs/DATA_MODEL.md)
|
|
88
|
+
- [Privacy and security](docs/PRIVACY_SECURITY.md)
|
|
89
|
+
- [Product and UX specification](docs/PRODUCT_UX.md)
|
|
90
|
+
- [Screenpipe reuse audit](docs/SCREENPIPE_REUSE.md)
|
|
91
|
+
- [ActivityWatch integration research](docs/ACTIVITYWATCH_INTEGRATION_RESEARCH.md)
|
|
92
|
+
|
|
93
|
+
## Proposed repository shape
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
apps/
|
|
97
|
+
desktop/ Tauri window, tray, onboarding, settings
|
|
98
|
+
crates/
|
|
99
|
+
daytrace-core/ orchestration, policies, domain events
|
|
100
|
+
daytrace-capture/ platform-neutral capture traits
|
|
101
|
+
daytrace-platform-*/ macOS, Windows, and Linux adapters
|
|
102
|
+
daytrace-extract/ accessibility, OCR, VAD, transcription
|
|
103
|
+
daytrace-store/ SQLite migrations, FTS, retention
|
|
104
|
+
daytrace-summary/ segmentation and provider-neutral summaries
|
|
105
|
+
daytrace-sync/ deterministic export and GitHub sync
|
|
106
|
+
daytrace-secrets/ OS credential-store abstraction
|
|
107
|
+
extensions/
|
|
108
|
+
browser/ optional Chromium/Firefox tab metadata bridge
|
|
109
|
+
docs/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Target outcome
|
|
113
|
+
|
|
114
|
+
The v1 release is a signed, auto-updating macOS, Windows, and Linux desktop app
|
|
115
|
+
that can run for an eight-hour day without retaining raw media, recover cleanly
|
|
116
|
+
from sleep and device changes, search a local journal, and create an editable
|
|
117
|
+
end-of-day summary. See the implementation plan for measurable release gates.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daytrace"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Deterministic daily summaries from ActivityWatch"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aw-client>=0.5.15,<0.6",
|
|
15
|
+
"tzlocal>=5.4,<6",
|
|
16
|
+
"tzdata>=2026.3; sys_platform == 'win32'",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
daytrace = "daytrace.cli:entrypoint"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=9.1,<10"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
addopts = "-ra"
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["uv_build>=0.12.13,<0.13"]
|
|
3
|
+
build-backend = "uv_build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "daytrace"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Deterministic daily summaries from ActivityWatch"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"aw-client>=0.5.15,<0.6",
|
|
15
|
+
"tzlocal>=5.4,<6",
|
|
16
|
+
"tzdata>=2026.3; sys_platform == 'win32'",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
daytrace = "daytrace.cli:entrypoint"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = ["pytest>=9.1,<10"]
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
addopts = "-ra"
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import date, timezone
|
|
6
|
+
|
|
7
|
+
from daytrace.markdown import render_markdown
|
|
8
|
+
from daytrace.normalize import SUPPORTED_BUCKET_TYPES, normalize_events
|
|
9
|
+
from daytrace.report import build_report
|
|
10
|
+
from daytrace.source import ActivitySource, AwClientSource
|
|
11
|
+
from daytrace.time import resolve_day
|
|
12
|
+
from daytrace.transform import filter_project, merge_adjacent, remove_afk
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
DEFAULT_SERVER = "http://127.0.0.1:5600"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def summarize_day(
|
|
19
|
+
day: date,
|
|
20
|
+
project: str | None = None,
|
|
21
|
+
*,
|
|
22
|
+
server: str = DEFAULT_SERVER,
|
|
23
|
+
timezone_name: str | None = None,
|
|
24
|
+
source: ActivitySource | None = None,
|
|
25
|
+
warn: Callable[[str], None] | None = None,
|
|
26
|
+
) -> str:
|
|
27
|
+
warning = warn or logging.getLogger("daytrace").warning
|
|
28
|
+
window = resolve_day(day, timezone_name)
|
|
29
|
+
activity_source = source or AwClientSource.from_url(server)
|
|
30
|
+
activity_source.get_info()
|
|
31
|
+
buckets = activity_source.list_buckets()
|
|
32
|
+
supported = tuple(
|
|
33
|
+
bucket for bucket in buckets if bucket.type in SUPPORTED_BUCKET_TYPES
|
|
34
|
+
)
|
|
35
|
+
unknown_count = len(buckets) - len(supported)
|
|
36
|
+
if unknown_count:
|
|
37
|
+
noun = "bucket" if unknown_count == 1 else "buckets"
|
|
38
|
+
warning(f"ignored {unknown_count} unsupported ActivityWatch {noun}")
|
|
39
|
+
|
|
40
|
+
records = tuple(
|
|
41
|
+
record
|
|
42
|
+
for bucket in supported
|
|
43
|
+
for record in normalize_events(
|
|
44
|
+
bucket,
|
|
45
|
+
activity_source.get_events(
|
|
46
|
+
bucket.id,
|
|
47
|
+
window.start.astimezone(timezone.utc),
|
|
48
|
+
window.end.astimezone(timezone.utc),
|
|
49
|
+
),
|
|
50
|
+
window,
|
|
51
|
+
warning,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
transformed = merge_adjacent(filter_project(remove_afk(records), project))
|
|
55
|
+
return render_markdown(build_report(day, window, transformed, project))
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from datetime import date
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import NoReturn
|
|
9
|
+
from zoneinfo import ZoneInfoNotFoundError
|
|
10
|
+
|
|
11
|
+
from daytrace.activitywatch import DEFAULT_SERVER, summarize_day
|
|
12
|
+
from daytrace.source import ActivityWatchConnectionError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _date(value: str) -> date:
|
|
16
|
+
try:
|
|
17
|
+
return date.fromisoformat(value)
|
|
18
|
+
except ValueError as exc:
|
|
19
|
+
raise argparse.ArgumentTypeError("expected YYYY-MM-DD") from exc
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
23
|
+
parser = argparse.ArgumentParser(prog="daytrace")
|
|
24
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
activitywatch = commands.add_parser(
|
|
26
|
+
"activitywatch", help="summarize ActivityWatch data"
|
|
27
|
+
)
|
|
28
|
+
activitywatch.add_argument("--date", required=True, type=_date)
|
|
29
|
+
activitywatch.add_argument("--project")
|
|
30
|
+
activitywatch.add_argument("--server", default=DEFAULT_SERVER)
|
|
31
|
+
activitywatch.add_argument("--timezone", dest="timezone_name")
|
|
32
|
+
activitywatch.add_argument("--output", type=Path)
|
|
33
|
+
return parser
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
37
|
+
args = build_parser().parse_args(argv)
|
|
38
|
+
try:
|
|
39
|
+
markdown = summarize_day(
|
|
40
|
+
args.date,
|
|
41
|
+
args.project,
|
|
42
|
+
server=args.server,
|
|
43
|
+
timezone_name=args.timezone_name,
|
|
44
|
+
warn=lambda message: print(f"warning: {message}", file=sys.stderr),
|
|
45
|
+
)
|
|
46
|
+
except ZoneInfoNotFoundError:
|
|
47
|
+
print(f"error: invalid timezone {args.timezone_name!r}", file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
except ValueError:
|
|
50
|
+
print("error: invalid ActivityWatch server URL", file=sys.stderr)
|
|
51
|
+
return 1
|
|
52
|
+
except ActivityWatchConnectionError:
|
|
53
|
+
print(
|
|
54
|
+
f"error: ActivityWatch is not reachable at {args.server}; "
|
|
55
|
+
"start ActivityWatch or pass --server",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
if args.output:
|
|
61
|
+
try:
|
|
62
|
+
lf_markdown = markdown.replace("\r\n", "\n").replace("\r", "\n")
|
|
63
|
+
args.output.write_bytes(lf_markdown.encode("utf-8"))
|
|
64
|
+
except OSError:
|
|
65
|
+
print(
|
|
66
|
+
f"error: could not write output file {str(args.output)!r}",
|
|
67
|
+
file=sys.stderr,
|
|
68
|
+
)
|
|
69
|
+
return 1
|
|
70
|
+
else:
|
|
71
|
+
sys.stdout.write(markdown)
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def entrypoint() -> NoReturn:
|
|
76
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import re
|
|
5
|
+
from zoneinfo import ZoneInfo
|
|
6
|
+
|
|
7
|
+
from daytrace.models import ActivityRecord, ActivityReport, SourceKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SOURCE_LABEL = {
|
|
11
|
+
SourceKind.WINDOW: "Current window",
|
|
12
|
+
SourceKind.EDITOR: "Editor",
|
|
13
|
+
SourceKind.BROWSER: "Browser",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def format_duration(seconds: float) -> str:
|
|
18
|
+
minutes = max(1, int((seconds + 30) // 60))
|
|
19
|
+
hours, minutes = divmod(minutes, 60)
|
|
20
|
+
if hours and minutes:
|
|
21
|
+
return f"{hours}h {minutes}m"
|
|
22
|
+
if hours:
|
|
23
|
+
return f"{hours}h"
|
|
24
|
+
return f"{minutes}m"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _single_line(value: str) -> str:
|
|
28
|
+
return " ".join(value.replace("\r", " ").replace("\n", " ").split())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _escape(value: str) -> str:
|
|
32
|
+
escaped = html.escape(_single_line(value), quote=False).replace("\\", "\\\\")
|
|
33
|
+
return re.sub(r"([`*_{}\[\]#+|])", r"\\\1", escaped)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _code(value: str) -> str:
|
|
37
|
+
value = _single_line(value)
|
|
38
|
+
longest = max((len(run) for run in re.findall(r"`+", value)), default=0)
|
|
39
|
+
fence = "`" * (longest + 1)
|
|
40
|
+
padding = " " if value.startswith("`") or value.endswith("`") else ""
|
|
41
|
+
return f"{fence}{padding}{value}{padding}{fence}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _basename(value: str | None) -> str | None:
|
|
45
|
+
return value.rsplit("/", 1)[-1].rsplit("\\", 1)[-1] if value else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _label(record: ActivityRecord) -> str:
|
|
49
|
+
if record.kind is SourceKind.WINDOW:
|
|
50
|
+
values = (record.app, record.title)
|
|
51
|
+
elif record.kind is SourceKind.BROWSER:
|
|
52
|
+
values = (record.url_host, record.title)
|
|
53
|
+
else:
|
|
54
|
+
values = (_basename(record.project), _basename(record.file), record.language)
|
|
55
|
+
label = " — ".join(_escape(value) for value in values if value)
|
|
56
|
+
return label or SOURCE_LABEL[record.kind]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_markdown(report: ActivityReport) -> str:
|
|
60
|
+
lines = [f"# Activity summary — {report.day.isoformat()}", ""]
|
|
61
|
+
if report.project is not None:
|
|
62
|
+
lines.append(f"Project filter: {_code(report.project)}")
|
|
63
|
+
lines.extend([f"Timezone: {_code(report.timezone_name)}", "", "## Overview", ""])
|
|
64
|
+
if not report.timeline:
|
|
65
|
+
return "\n".join(lines + ["No matching activity.", ""])
|
|
66
|
+
|
|
67
|
+
active = (
|
|
68
|
+
format_duration(report.active_seconds)
|
|
69
|
+
if report.active_seconds is not None
|
|
70
|
+
else "Unavailable"
|
|
71
|
+
)
|
|
72
|
+
lines.extend(
|
|
73
|
+
[
|
|
74
|
+
f"- Active matched time: {active}",
|
|
75
|
+
f"- Timeline entries: {len(report.timeline)}",
|
|
76
|
+
f"- Sources: {', '.join(SOURCE_LABEL[item] for item in report.sources)}",
|
|
77
|
+
"",
|
|
78
|
+
"## Timeline",
|
|
79
|
+
"",
|
|
80
|
+
]
|
|
81
|
+
)
|
|
82
|
+
zone = ZoneInfo(report.timezone_name)
|
|
83
|
+
for record in report.timeline:
|
|
84
|
+
start = record.start.astimezone(zone).strftime("%H:%M")
|
|
85
|
+
end = record.end.astimezone(zone).strftime("%H:%M")
|
|
86
|
+
lines.append(
|
|
87
|
+
f"- {start}–{end} ({format_duration(record.duration_seconds)}) — {_label(record)}"
|
|
88
|
+
)
|
|
89
|
+
if report.applications:
|
|
90
|
+
lines.extend(["", "## Applications", ""])
|
|
91
|
+
lines.extend(
|
|
92
|
+
f"- {_escape(item.app)} — {format_duration(item.seconds)}"
|
|
93
|
+
for item in report.applications
|
|
94
|
+
)
|
|
95
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import date, datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SourceKind(StrEnum):
|
|
11
|
+
WINDOW = "current-window"
|
|
12
|
+
AFK = "afk"
|
|
13
|
+
EDITOR = "editor"
|
|
14
|
+
BROWSER = "browser"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ServerEndpoint:
|
|
19
|
+
protocol: str
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class ServerInfo:
|
|
26
|
+
version: str
|
|
27
|
+
testing: bool
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class DayWindow:
|
|
32
|
+
timezone_name: str
|
|
33
|
+
start: datetime
|
|
34
|
+
end: datetime
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class RawBucket:
|
|
39
|
+
id: str
|
|
40
|
+
type: str
|
|
41
|
+
client: str
|
|
42
|
+
hostname: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class RawEvent:
|
|
47
|
+
id: str
|
|
48
|
+
timestamp: datetime
|
|
49
|
+
duration_seconds: float
|
|
50
|
+
data: Mapping[str, object]
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
object.__setattr__(self, "data", MappingProxyType(dict(self.data)))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class ActivityRecord:
|
|
58
|
+
event_id: str
|
|
59
|
+
bucket_id: str
|
|
60
|
+
kind: SourceKind
|
|
61
|
+
start: datetime
|
|
62
|
+
end: datetime
|
|
63
|
+
app: str | None = None
|
|
64
|
+
title: str | None = None
|
|
65
|
+
project: str | None = None
|
|
66
|
+
file: str | None = None
|
|
67
|
+
url_host: str | None = None
|
|
68
|
+
language: str | None = None
|
|
69
|
+
status: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def duration_seconds(self) -> float:
|
|
73
|
+
return (self.end - self.start).total_seconds()
|
|
74
|
+
|
|
75
|
+
def content_key(self) -> tuple[object, ...]:
|
|
76
|
+
return (
|
|
77
|
+
self.bucket_id,
|
|
78
|
+
self.kind,
|
|
79
|
+
self.app,
|
|
80
|
+
self.title,
|
|
81
|
+
self.project,
|
|
82
|
+
self.file,
|
|
83
|
+
self.url_host,
|
|
84
|
+
self.language,
|
|
85
|
+
self.status,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class ApplicationTotal:
|
|
91
|
+
app: str
|
|
92
|
+
seconds: float
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class ActivityReport:
|
|
97
|
+
day: date
|
|
98
|
+
timezone_name: str
|
|
99
|
+
project: str | None
|
|
100
|
+
active_seconds: float | None
|
|
101
|
+
sources: tuple[SourceKind, ...]
|
|
102
|
+
timeline: tuple[ActivityRecord, ...]
|
|
103
|
+
applications: tuple[ApplicationTotal, ...]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
4
|
+
from datetime import timedelta, timezone
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from daytrace.models import ActivityRecord, DayWindow, RawBucket, RawEvent, SourceKind
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
ONE_SECOND = timedelta(seconds=1)
|
|
11
|
+
SUPPORTED_BUCKET_TYPES: Mapping[str, SourceKind] = {
|
|
12
|
+
"currentwindow": SourceKind.WINDOW,
|
|
13
|
+
"afkstatus": SourceKind.AFK,
|
|
14
|
+
"app.editor.activity": SourceKind.EDITOR,
|
|
15
|
+
"web.tab.current": SourceKind.BROWSER,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _text(data: Mapping[str, object], key: str) -> str | None:
|
|
20
|
+
value = data.get(key)
|
|
21
|
+
return value if isinstance(value, str) and value else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _url_host(data: Mapping[str, object]) -> str | None:
|
|
25
|
+
value = _text(data, "url")
|
|
26
|
+
if not value:
|
|
27
|
+
return None
|
|
28
|
+
try:
|
|
29
|
+
return urlsplit(value).hostname
|
|
30
|
+
except ValueError:
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def normalize_events(
|
|
35
|
+
bucket: RawBucket,
|
|
36
|
+
events: Iterable[RawEvent],
|
|
37
|
+
window: DayWindow,
|
|
38
|
+
warn: Callable[[str], None],
|
|
39
|
+
) -> tuple[ActivityRecord, ...]:
|
|
40
|
+
kind = SUPPORTED_BUCKET_TYPES[bucket.type]
|
|
41
|
+
normalized: list[ActivityRecord] = []
|
|
42
|
+
window_start = window.start.astimezone(timezone.utc)
|
|
43
|
+
window_end = window.end.astimezone(timezone.utc)
|
|
44
|
+
|
|
45
|
+
for event in events:
|
|
46
|
+
if event.timestamp.tzinfo is None:
|
|
47
|
+
warn(
|
|
48
|
+
f"skipped naive timestamp for event {event.id!r} in bucket {bucket.id!r}"
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
raw_start = event.timestamp.astimezone(timezone.utc)
|
|
52
|
+
start = max(raw_start, window_start)
|
|
53
|
+
end = min(raw_start + event.duration_seconds * ONE_SECOND, window_end)
|
|
54
|
+
if end <= start:
|
|
55
|
+
warn(f"skipped non-positive event {event.id!r} in bucket {bucket.id!r}")
|
|
56
|
+
continue
|
|
57
|
+
normalized.append(
|
|
58
|
+
ActivityRecord(
|
|
59
|
+
event_id=event.id,
|
|
60
|
+
bucket_id=bucket.id,
|
|
61
|
+
kind=kind,
|
|
62
|
+
start=start,
|
|
63
|
+
end=end,
|
|
64
|
+
app=_text(event.data, "app"),
|
|
65
|
+
title=_text(event.data, "title"),
|
|
66
|
+
project=_text(event.data, "project"),
|
|
67
|
+
file=_text(event.data, "file"),
|
|
68
|
+
url_host=_url_host(event.data),
|
|
69
|
+
language=_text(event.data, "language"),
|
|
70
|
+
status=_text(event.data, "status"),
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return tuple(
|
|
75
|
+
sorted(normalized, key=lambda item: (item.start, item.bucket_id, item.event_id))
|
|
76
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from datetime import date
|
|
3
|
+
|
|
4
|
+
from daytrace.models import (
|
|
5
|
+
ActivityRecord,
|
|
6
|
+
ActivityReport,
|
|
7
|
+
ApplicationTotal,
|
|
8
|
+
DayWindow,
|
|
9
|
+
SourceKind,
|
|
10
|
+
)
|
|
11
|
+
from daytrace.transform import partition_window_seconds
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SOURCE_ORDER = {
|
|
15
|
+
SourceKind.WINDOW: 0,
|
|
16
|
+
SourceKind.EDITOR: 1,
|
|
17
|
+
SourceKind.BROWSER: 2,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_report(
|
|
22
|
+
day: date,
|
|
23
|
+
window: DayWindow,
|
|
24
|
+
records: tuple[ActivityRecord, ...],
|
|
25
|
+
project: str | None,
|
|
26
|
+
) -> ActivityReport:
|
|
27
|
+
timeline = tuple(
|
|
28
|
+
sorted(records, key=lambda item: (item.start, item.bucket_id, item.event_id))
|
|
29
|
+
)
|
|
30
|
+
allocation = partition_window_seconds(timeline)
|
|
31
|
+
app_seconds: dict[str, float] = defaultdict(float)
|
|
32
|
+
for record, seconds in allocation:
|
|
33
|
+
app_seconds[record.app or "Unknown application"] += seconds
|
|
34
|
+
applications = tuple(
|
|
35
|
+
ApplicationTotal(app, seconds)
|
|
36
|
+
for app, seconds in sorted(
|
|
37
|
+
app_seconds.items(),
|
|
38
|
+
key=lambda item: (-item[1], item[0].casefold(), item[0]),
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
sources = tuple(
|
|
42
|
+
sorted({item.kind for item in timeline}, key=SOURCE_ORDER.__getitem__)
|
|
43
|
+
)
|
|
44
|
+
return ActivityReport(
|
|
45
|
+
day=day,
|
|
46
|
+
timezone_name=window.timezone_name,
|
|
47
|
+
project=project,
|
|
48
|
+
active_seconds=sum(item.seconds for item in applications)
|
|
49
|
+
if allocation
|
|
50
|
+
else None,
|
|
51
|
+
sources=sources,
|
|
52
|
+
timeline=timeline,
|
|
53
|
+
applications=applications,
|
|
54
|
+
)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Any, Protocol
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
from aw_client import ActivityWatchClient
|
|
9
|
+
|
|
10
|
+
from daytrace.models import RawBucket, RawEvent, ServerEndpoint, ServerInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ActivityWatchConnectionError(RuntimeError):
|
|
14
|
+
"""A privacy-safe failure while reading ActivityWatch."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ActivitySource(Protocol):
|
|
18
|
+
def get_info(self) -> ServerInfo: ...
|
|
19
|
+
|
|
20
|
+
def list_buckets(self) -> tuple[RawBucket, ...]: ...
|
|
21
|
+
|
|
22
|
+
def get_events(
|
|
23
|
+
self, bucket_id: str, start: datetime, end: datetime
|
|
24
|
+
) -> tuple[RawEvent, ...]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_server_url(url: str) -> ServerEndpoint:
|
|
28
|
+
parsed = urlsplit(url)
|
|
29
|
+
valid = (
|
|
30
|
+
parsed.scheme in {"http", "https"}
|
|
31
|
+
and parsed.hostname is not None
|
|
32
|
+
and parsed.username is None
|
|
33
|
+
and parsed.password is None
|
|
34
|
+
and parsed.path in {"", "/"}
|
|
35
|
+
and not parsed.query
|
|
36
|
+
and not parsed.fragment
|
|
37
|
+
)
|
|
38
|
+
if not valid:
|
|
39
|
+
raise ValueError("invalid ActivityWatch server URL")
|
|
40
|
+
try:
|
|
41
|
+
port = parsed.port or 5600
|
|
42
|
+
except ValueError as exc:
|
|
43
|
+
raise ValueError("invalid ActivityWatch server URL") from exc
|
|
44
|
+
return ServerEndpoint(parsed.scheme, parsed.hostname, port)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AwClientSource:
|
|
48
|
+
def __init__(self, client: ActivityWatchClient | Any) -> None:
|
|
49
|
+
self._client = client
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_url(cls, url: str) -> AwClientSource:
|
|
53
|
+
endpoint = parse_server_url(url)
|
|
54
|
+
client = ActivityWatchClient(
|
|
55
|
+
"daytrace",
|
|
56
|
+
host=endpoint.host,
|
|
57
|
+
port=endpoint.port,
|
|
58
|
+
protocol=endpoint.protocol,
|
|
59
|
+
)
|
|
60
|
+
return cls(client)
|
|
61
|
+
|
|
62
|
+
def get_info(self) -> ServerInfo:
|
|
63
|
+
try:
|
|
64
|
+
info = self._client.get_info()
|
|
65
|
+
return ServerInfo(
|
|
66
|
+
str(info.get("version", "unknown")), bool(info.get("testing"))
|
|
67
|
+
)
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
raise ActivityWatchConnectionError(
|
|
70
|
+
"ActivityWatch info request failed"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
def list_buckets(self) -> tuple[RawBucket, ...]:
|
|
74
|
+
try:
|
|
75
|
+
buckets = self._client.get_buckets()
|
|
76
|
+
return tuple(
|
|
77
|
+
RawBucket(
|
|
78
|
+
id=bucket_id,
|
|
79
|
+
type=str(data.get("type", "")),
|
|
80
|
+
client=str(data.get("client", "")),
|
|
81
|
+
hostname=str(data.get("hostname", "")),
|
|
82
|
+
)
|
|
83
|
+
for bucket_id, data in sorted(buckets.items())
|
|
84
|
+
)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise ActivityWatchConnectionError(
|
|
87
|
+
"ActivityWatch bucket request failed"
|
|
88
|
+
) from exc
|
|
89
|
+
|
|
90
|
+
def get_events(
|
|
91
|
+
self, bucket_id: str, start: datetime, end: datetime
|
|
92
|
+
) -> tuple[RawEvent, ...]:
|
|
93
|
+
try:
|
|
94
|
+
events: Sequence[Any] = self._client.get_events(
|
|
95
|
+
bucket_id, start=start, end=end
|
|
96
|
+
)
|
|
97
|
+
return tuple(
|
|
98
|
+
RawEvent(
|
|
99
|
+
id=str(event.id),
|
|
100
|
+
timestamp=event.timestamp,
|
|
101
|
+
duration_seconds=(
|
|
102
|
+
event.duration.total_seconds()
|
|
103
|
+
if isinstance(event.duration, timedelta)
|
|
104
|
+
else float(event.duration)
|
|
105
|
+
),
|
|
106
|
+
data=event.data,
|
|
107
|
+
)
|
|
108
|
+
for event in events
|
|
109
|
+
)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise ActivityWatchConnectionError(
|
|
112
|
+
f"ActivityWatch event request failed for bucket {bucket_id!r}"
|
|
113
|
+
) from exc
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime, time, timedelta
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
from tzlocal import get_localzone_name
|
|
7
|
+
|
|
8
|
+
from daytrace.models import DayWindow
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_day(day: date, timezone_name: str | None = None) -> DayWindow:
|
|
12
|
+
name = timezone_name or get_localzone_name()
|
|
13
|
+
zone = ZoneInfo(name)
|
|
14
|
+
start = datetime.combine(day, time.min, zone)
|
|
15
|
+
end = datetime.combine(day + timedelta(days=1), time.min, zone)
|
|
16
|
+
return DayWindow(timezone_name=name, start=start, end=end)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
from daytrace.models import ActivityRecord, SourceKind
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sort(records: Iterable[ActivityRecord]) -> tuple[ActivityRecord, ...]:
|
|
12
|
+
return tuple(
|
|
13
|
+
sorted(records, key=lambda item: (item.start, item.bucket_id, item.event_id))
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _union(
|
|
18
|
+
intervals: Iterable[tuple[datetime, datetime]],
|
|
19
|
+
) -> list[tuple[datetime, datetime]]:
|
|
20
|
+
merged: list[tuple[datetime, datetime]] = []
|
|
21
|
+
for start, end in sorted(intervals):
|
|
22
|
+
if not merged or start > merged[-1][1]:
|
|
23
|
+
merged.append((start, end))
|
|
24
|
+
else:
|
|
25
|
+
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
|
26
|
+
return merged
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _subtract_interval(
|
|
30
|
+
fragments: list[tuple[datetime, datetime]],
|
|
31
|
+
away_start: datetime,
|
|
32
|
+
away_end: datetime,
|
|
33
|
+
) -> list[tuple[datetime, datetime]]:
|
|
34
|
+
output: list[tuple[datetime, datetime]] = []
|
|
35
|
+
for start, end in fragments:
|
|
36
|
+
if away_end <= start or away_start >= end:
|
|
37
|
+
output.append((start, end))
|
|
38
|
+
continue
|
|
39
|
+
if start < away_start:
|
|
40
|
+
output.append((start, away_start))
|
|
41
|
+
if away_end < end:
|
|
42
|
+
output.append((away_end, end))
|
|
43
|
+
return output
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def remove_afk(records: Iterable[ActivityRecord]) -> tuple[ActivityRecord, ...]:
|
|
47
|
+
items = tuple(records)
|
|
48
|
+
away = _union(
|
|
49
|
+
(item.start, item.end)
|
|
50
|
+
for item in items
|
|
51
|
+
if item.kind is SourceKind.AFK and (item.status or "").casefold() == "afk"
|
|
52
|
+
)
|
|
53
|
+
output: list[ActivityRecord] = []
|
|
54
|
+
for item in items:
|
|
55
|
+
if item.kind is SourceKind.AFK:
|
|
56
|
+
continue
|
|
57
|
+
fragments = [(item.start, item.end)]
|
|
58
|
+
for away_start, away_end in away:
|
|
59
|
+
fragments = _subtract_interval(fragments, away_start, away_end)
|
|
60
|
+
output.extend(replace(item, start=start, end=end) for start, end in fragments)
|
|
61
|
+
return _sort(output)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def filter_project(
|
|
65
|
+
records: Iterable[ActivityRecord], project: str | None
|
|
66
|
+
) -> tuple[ActivityRecord, ...]:
|
|
67
|
+
if project is None:
|
|
68
|
+
return _sort(records)
|
|
69
|
+
needle = project.casefold()
|
|
70
|
+
return _sort(
|
|
71
|
+
item
|
|
72
|
+
for item in records
|
|
73
|
+
if any(
|
|
74
|
+
needle in value.casefold()
|
|
75
|
+
for value in (
|
|
76
|
+
item.project,
|
|
77
|
+
item.app,
|
|
78
|
+
item.title,
|
|
79
|
+
item.file,
|
|
80
|
+
item.url_host,
|
|
81
|
+
item.bucket_id,
|
|
82
|
+
)
|
|
83
|
+
if value
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def merge_adjacent(
|
|
89
|
+
records: Iterable[ActivityRecord],
|
|
90
|
+
maximum_gap: timedelta = timedelta(seconds=60),
|
|
91
|
+
) -> tuple[ActivityRecord, ...]:
|
|
92
|
+
by_bucket: dict[str, list[ActivityRecord]] = defaultdict(list)
|
|
93
|
+
for item in _sort(records):
|
|
94
|
+
bucket_records = by_bucket[item.bucket_id]
|
|
95
|
+
if (
|
|
96
|
+
bucket_records
|
|
97
|
+
and bucket_records[-1].content_key() == item.content_key()
|
|
98
|
+
and item.start - bucket_records[-1].end <= maximum_gap
|
|
99
|
+
):
|
|
100
|
+
bucket_records[-1] = replace(
|
|
101
|
+
bucket_records[-1], end=max(bucket_records[-1].end, item.end)
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
bucket_records.append(item)
|
|
105
|
+
return _sort(
|
|
106
|
+
item for bucket_records in by_bucket.values() for item in bucket_records
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def partition_window_seconds(
|
|
111
|
+
records: Iterable[ActivityRecord],
|
|
112
|
+
) -> tuple[tuple[ActivityRecord, float], ...]:
|
|
113
|
+
windows = tuple(item for item in records if item.kind is SourceKind.WINDOW)
|
|
114
|
+
boundaries = sorted({point for item in windows for point in (item.start, item.end)})
|
|
115
|
+
totals: dict[ActivityRecord, float] = defaultdict(float)
|
|
116
|
+
for start, end in zip(boundaries, boundaries[1:]):
|
|
117
|
+
candidates = [item for item in windows if item.start < end and item.end > start]
|
|
118
|
+
if candidates:
|
|
119
|
+
winner = min(candidates, key=lambda item: (item.bucket_id, item.event_id))
|
|
120
|
+
totals[winner] += (end - start).total_seconds()
|
|
121
|
+
return tuple(
|
|
122
|
+
sorted(totals.items(), key=lambda pair: (pair[0].bucket_id, pair[0].event_id))
|
|
123
|
+
)
|