snapshot of https://github.com/ActivityWatch/aw-core.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest, py-3.8 (push) Successful in 4m21s
Lint / lint (push) Successful in 25s
Build / Test on macOS-latest, py-3.8 (push) Has been cancelled
Build / Test on windows-latest, py-3.8 (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:17:07 +05:00
commit 8757f2fd5e
73 changed files with 8869 additions and 0 deletions

View File

@@ -0,0 +1,150 @@
import logging
from copy import deepcopy
from typing import List, Optional, Tuple
from aw_core.models import Event
from timeslot import Timeslot
from .filter_period_intersect import _get_event_period
logger = logging.getLogger(__name__)
def merge_subwatcher_fields(
base_events: List[Event],
subwatcher_events: List[Event],
keys: List[str],
conflict: str = "base_wins",
) -> List[Event]:
"""
Split each event in *base_events* on overlapping subwatcher boundaries and
copy the named *keys* from the matching subwatcher event into each segment's
``data`` dict.
Unlike the ``concat`` workaround, this does not fabricate extra duration.
App/title/duration aggregations stay correct because the output still covers
exactly the same total time as *base_events*; only the segmentation changes
where subwatcher fields actually change.
This is the backend primitive that lets every client (webui, native UIs,
exporters) categorize by subwatcher fields (browser ``url``/``$domain``;
editor ``project``/``file``/``language``) without bespoke per-watcher
client-side code.
Args:
base_events: The canonical window/afk-filtered stream to enrich.
subwatcher_events: Events from a subwatcher bucket (e.g. aw-watcher-vim,
aw-watcher-web). Should already be clipped via
``filter_period_intersect`` before passing here.
keys: Which keys to copy from the subwatcher event into the base event.
Keys already present in the base event are left untouched when
``conflict="base_wins"`` (default).
conflict: ``"base_wins"`` (default) — base event's existing keys are
never overwritten; subwatcher fields are purely additive.
``"sub_wins"`` — subwatcher fields overwrite base fields.
Returns:
A new list of base event segments with subwatcher fields injected.
Events in *base_events* that have no overlapping subwatcher event are
returned with their original data unchanged.
Example::
window_events = query_bucket(bid_window)
editor_events = flood(query_bucket(bid_editor))
editor_events = filter_period_intersect(editor_events, window_events)
window_events = merge_subwatcher_fields(
window_events, editor_events, ["project", "file", "language"]
)
# Now categorize(window_events, ...) can match on "project"/"file"
Note on overlap:
Base events are split at the clipped subwatcher boundaries so each
output segment only carries the subwatcher fields that were actually
present during that slice of time. If multiple subwatcher events cover
the same slice, the most recent one wins so a later transition does not
get smeared backward by an older, longer pulse.
"""
if conflict not in ("base_wins", "sub_wins"):
raise ValueError(
f"conflict must be 'base_wins' or 'sub_wins', got {conflict!r}"
)
if not subwatcher_events or not keys:
return [deepcopy(e) for e in base_events]
# Build a sorted copy so we can do a linear scan
sub_sorted = sorted(subwatcher_events, key=lambda e: e.timestamp)
result: List[Event] = []
for base in base_events:
base_period = _get_event_period(base)
overlapping: List[Tuple[Event, Timeslot]] = []
boundaries = {base_period.start, base_period.end}
for sub in sub_sorted:
sub_period = _get_event_period(sub)
# Once sub starts after base ends we can stop
if sub_period.start >= base_period.end:
break
# Skip sub events that end before base starts
if sub_period.end <= base_period.start:
continue
ip = base_period.intersection(sub_period)
if ip:
overlapping.append((sub, sub_period))
boundaries.add(ip.start)
boundaries.add(ip.end)
if not overlapping:
result.append(deepcopy(base))
continue
boundary_points = sorted(boundaries)
base_segments: List[Event] = []
for start, end in zip(boundary_points, boundary_points[1:]):
segment_period = Timeslot(start, end)
best_sub: Optional[Event] = None
best_sub_period: Optional[Timeslot] = None
for sub, sub_period in overlapping:
if not segment_period.intersection(sub_period):
continue
# Later subwatcher events should supersede older overlapping
# pulses on the shared slice instead of letting stale data linger.
if (
best_sub is None
or sub.timestamp > best_sub.timestamp
or (
sub.timestamp == best_sub.timestamp
and best_sub_period is not None
and sub_period.end > best_sub_period.end
)
):
best_sub = sub
best_sub_period = sub_period
enriched = deepcopy(base)
enriched.timestamp = start
enriched.duration = end - start
if best_sub is not None:
for key in keys:
if key in best_sub.data:
if conflict == "base_wins" and key in enriched.data:
continue
enriched.data[key] = deepcopy(best_sub.data[key])
if (
base_segments
and base_segments[-1].timestamp + base_segments[-1].duration
== enriched.timestamp
and base_segments[-1].data == enriched.data
):
base_segments[-1].duration += enriched.duration
else:
base_segments.append(enriched)
result.extend(base_segments)
return result