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
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:
44
aw_transform/__init__.py
Normal file
44
aw_transform/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from .filter_keyvals import filter_keyvals, filter_keyvals_regex
|
||||
from .filter_period_intersect import filter_period_intersect, period_union, union
|
||||
from .heartbeats import heartbeat_merge, heartbeat_reduce
|
||||
from .merge_events_by_keys import merge_events_by_keys
|
||||
from .chunk_events_by_key import chunk_events_by_key
|
||||
from .sort_by import (
|
||||
sort_by_timestamp,
|
||||
sort_by_duration,
|
||||
sum_durations,
|
||||
concat,
|
||||
limit_events,
|
||||
)
|
||||
from .split_url_events import split_url_events
|
||||
from .merge_subwatcher_fields import merge_subwatcher_fields
|
||||
from .simplify import simplify_string
|
||||
from .flood import flood
|
||||
from .classify import categorize, tag, Rule
|
||||
from .union_no_overlap import union_no_overlap
|
||||
|
||||
__all__ = [
|
||||
"flood",
|
||||
"concat",
|
||||
"categorize",
|
||||
"tag",
|
||||
"Rule",
|
||||
"period_union",
|
||||
"filter_period_intersect",
|
||||
"union",
|
||||
"union_no_overlap",
|
||||
"concat",
|
||||
"sum_durations",
|
||||
"sort_by_timestamp",
|
||||
"sort_by_duration",
|
||||
"heartbeat_reduce",
|
||||
"heartbeat_merge",
|
||||
"merge_events_by_keys",
|
||||
"merge_subwatcher_fields",
|
||||
"chunk_events_by_key",
|
||||
"limit_events",
|
||||
"filter_keyvals",
|
||||
"filter_keyvals_regex",
|
||||
"split_url_events",
|
||||
"simplify_string",
|
||||
]
|
||||
39
aw_transform/chunk_events_by_key.py
Normal file
39
aw_transform/chunk_events_by_key.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def chunk_events_by_key(
|
||||
events: List[Event], key: str, pulsetime: float = 5.0
|
||||
) -> List[Event]:
|
||||
"""
|
||||
"Chunks" adjacent events together which have the same value for a key, and stores the
|
||||
original events in the :code:`subevents` key of the new event.
|
||||
"""
|
||||
chunked_events: List[Event] = []
|
||||
for event in events:
|
||||
if key not in event.data:
|
||||
break
|
||||
timediff = timedelta(seconds=999999999) # FIXME: ugly but works
|
||||
if len(chunked_events) > 0:
|
||||
timediff = event.timestamp - (events[-1].timestamp + events[-1].duration)
|
||||
if (
|
||||
len(chunked_events) > 0
|
||||
and chunked_events[-1].data[key] == event.data[key]
|
||||
and timediff < timedelta(seconds=pulsetime)
|
||||
):
|
||||
chunked_event = chunked_events[-1]
|
||||
chunked_event.duration += event.duration
|
||||
chunked_event.data["subevents"].append(event)
|
||||
else:
|
||||
data = {key: event.data[key], "subevents": [event]}
|
||||
chunked_event = Event(
|
||||
timestamp=event.timestamp, duration=event.duration, data=data
|
||||
)
|
||||
chunked_events.append(chunked_event)
|
||||
|
||||
return chunked_events
|
||||
122
aw_transform/classify.py
Normal file
122
aw_transform/classify.py
Normal file
@@ -0,0 +1,122 @@
|
||||
import json
|
||||
from typing import Pattern, List, Iterable, Tuple, Dict, Optional, Any
|
||||
import re
|
||||
|
||||
from aw_core import Event
|
||||
|
||||
|
||||
Tag = str
|
||||
Category = List[str]
|
||||
|
||||
|
||||
def _parse_optional_priority(rules: Dict[str, Any]) -> Optional[int]:
|
||||
if "priority" in rules:
|
||||
val = rules["priority"]
|
||||
elif "weight" in rules:
|
||||
val = rules["weight"]
|
||||
else:
|
||||
return None
|
||||
# bool is a subclass of int
|
||||
if isinstance(val, bool) or not isinstance(val, int):
|
||||
raise ValueError("priority/weight must be an integer")
|
||||
return val
|
||||
|
||||
|
||||
class Rule:
|
||||
regex: Optional[Pattern]
|
||||
select_keys: Optional[List[str]]
|
||||
ignore_case: bool
|
||||
priority: Optional[int]
|
||||
|
||||
def __init__(self, rules: Dict[str, Any]) -> None:
|
||||
self.select_keys = rules.get("select_keys", None)
|
||||
self.ignore_case = rules.get("ignore_case", False)
|
||||
self.priority = _parse_optional_priority(rules)
|
||||
|
||||
# NOTE: Also checks that the regex isn't an empty string (which would erroneously match everything)
|
||||
regex_str = rules.get("regex", None)
|
||||
self.regex = (
|
||||
re.compile(
|
||||
regex_str, (re.IGNORECASE if self.ignore_case else 0) | re.UNICODE
|
||||
)
|
||||
if regex_str
|
||||
else None
|
||||
)
|
||||
|
||||
def match(self, e: Event) -> bool:
|
||||
if self.select_keys:
|
||||
values = [e.data.get(key, None) for key in self.select_keys]
|
||||
else:
|
||||
values = list(e.data.values())
|
||||
if self.regex:
|
||||
for val in values:
|
||||
if isinstance(val, str) and self.regex.search(val):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def categorize(
|
||||
events: List[Event], classes: List[Tuple[Category, Rule]]
|
||||
) -> List[Event]:
|
||||
cache: Dict[str, Category] = {}
|
||||
for e in events:
|
||||
try:
|
||||
key = json.dumps(e.data, sort_keys=True)
|
||||
except TypeError:
|
||||
key = str(id(e.data))
|
||||
if key not in cache:
|
||||
cache[key] = _pick_category(
|
||||
[(_cls, rule) for _cls, rule in classes if rule.match(e)]
|
||||
)
|
||||
e.data["$category"] = list(cache[key])
|
||||
return events
|
||||
|
||||
|
||||
def _categorize_one(e: Event, classes: List[Tuple[Category, Rule]]) -> Event:
|
||||
e.data["$category"] = _pick_category(
|
||||
[(_cls, rule) for _cls, rule in classes if rule.match(e)]
|
||||
)
|
||||
return e
|
||||
|
||||
|
||||
def tag(events: List[Event], classes: List[Tuple[Tag, Rule]]) -> List[Event]:
|
||||
cache: Dict[str, List[Tag]] = {}
|
||||
for e in events:
|
||||
try:
|
||||
key = json.dumps(e.data, sort_keys=True)
|
||||
except TypeError:
|
||||
key = str(id(e.data))
|
||||
if key not in cache:
|
||||
cache[key] = [_cls for _cls, rule in classes if rule.match(e)]
|
||||
e.data["$tags"] = list(cache[key])
|
||||
return events
|
||||
|
||||
|
||||
def _tag_one(e: Event, classes: List[Tuple[Tag, Rule]]) -> Event:
|
||||
e.data["$tags"] = [_cls for _cls, rule in classes if rule.match(e)]
|
||||
return e
|
||||
|
||||
|
||||
def _effective_rank(category: Category, rule: Rule) -> int:
|
||||
# Integer-only. Default is depth * 10 so explicit priorities can slot
|
||||
# between nesting levels (depth 1 → 10, depth 2 → 20). Relative order of
|
||||
# unprioritized rules is unchanged.
|
||||
if rule.priority is not None:
|
||||
return rule.priority
|
||||
return len(category) * 10
|
||||
|
||||
|
||||
def _pick_category(matches: Iterable[Tuple[Category, Rule]]) -> Category:
|
||||
category: Category = ["Uncategorized"]
|
||||
rank: Optional[int] = None
|
||||
for cat, rule in matches:
|
||||
if not cat:
|
||||
continue
|
||||
item_rank = _effective_rank(cat, rule)
|
||||
# None means no match yet, so any non-empty category wins — including
|
||||
# an explicit priority below a signed 64-bit floor. Equal ranks keep
|
||||
# the later match (same contract as the old depth-only `>=`).
|
||||
if rank is None or item_rank >= rank:
|
||||
category = cat
|
||||
rank = item_rank
|
||||
return category
|
||||
28
aw_transform/filter_keyvals.py
Normal file
28
aw_transform/filter_keyvals.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import logging
|
||||
from typing import List
|
||||
import re
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filter_keyvals(
|
||||
events: List[Event], key: str, vals: List[str], exclude=False
|
||||
) -> List[Event]:
|
||||
def predicate(event):
|
||||
return key in event.data and event.data[key] in vals
|
||||
|
||||
if exclude:
|
||||
return [e for e in events if not predicate(e)]
|
||||
else:
|
||||
return [e for e in events if predicate(e)]
|
||||
|
||||
|
||||
def filter_keyvals_regex(events: List[Event], key: str, regex: str) -> List[Event]:
|
||||
r = re.compile(regex)
|
||||
|
||||
def predicate(event):
|
||||
return key in event.data and bool(r.findall(event.data[key]))
|
||||
|
||||
return [e for e in events if predicate(e)]
|
||||
173
aw_transform/filter_period_intersect.py
Normal file
173
aw_transform/filter_period_intersect.py
Normal file
@@ -0,0 +1,173 @@
|
||||
import logging
|
||||
from typing import List, Iterable, Tuple
|
||||
from copy import deepcopy
|
||||
|
||||
from aw_core import Event
|
||||
from timeslot import Timeslot
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_event_period(event: Event) -> Timeslot:
|
||||
start = event.timestamp
|
||||
end = start + event.duration
|
||||
return Timeslot(start, end)
|
||||
|
||||
|
||||
def _replace_event_period(event: Event, period: Timeslot) -> Event:
|
||||
e = deepcopy(event)
|
||||
e.timestamp = period.start
|
||||
e.duration = period.duration
|
||||
return e
|
||||
|
||||
|
||||
def _intersecting_eventpairs(
|
||||
events1: List[Event], events2: List[Event]
|
||||
) -> Iterable[Tuple[Event, Event, Timeslot]]:
|
||||
"""A generator that yields each overlapping pair of events from two eventlists along with a Timeslot of the intersection"""
|
||||
events1.sort(key=lambda e: e.timestamp)
|
||||
events2.sort(key=lambda e: e.timestamp)
|
||||
e1_i = 0
|
||||
e2_i = 0
|
||||
while e1_i < len(events1) and e2_i < len(events2):
|
||||
e1 = events1[e1_i]
|
||||
e2 = events2[e2_i]
|
||||
e1_p = _get_event_period(e1)
|
||||
e2_p = _get_event_period(e2)
|
||||
|
||||
ip = e1_p.intersection(e2_p)
|
||||
if ip:
|
||||
# If events intersected, yield events
|
||||
yield (e1, e2, ip)
|
||||
if e1_p.end <= e2_p.end:
|
||||
e1_i += 1
|
||||
else:
|
||||
e2_i += 1
|
||||
else:
|
||||
# No intersection, check if event is before/after filterevent
|
||||
if e1_p.end <= e2_p.start:
|
||||
# Event ended before filter event started
|
||||
e1_i += 1
|
||||
elif e2_p.end <= e1_p.start:
|
||||
# Event started after filter event ended
|
||||
e2_i += 1
|
||||
else:
|
||||
logger.error("Should be unreachable, skipping period")
|
||||
e1_i += 1
|
||||
e2_i += 1
|
||||
|
||||
|
||||
def filter_period_intersect(
|
||||
events: List[Event], filterevents: List[Event]
|
||||
) -> List[Event]:
|
||||
"""
|
||||
Filters away all events or time periods of events in which a
|
||||
filterevent does not have an intersecting time period.
|
||||
|
||||
Useful for example when you want to filter away events or
|
||||
part of events during which a user was AFK.
|
||||
|
||||
Usage:
|
||||
windowevents_notafk = filter_period_intersect(windowevents, notafkevents)
|
||||
|
||||
Example:
|
||||
.. code-block:: none
|
||||
|
||||
events1 | ======= ======== |
|
||||
events2 | ------ --- --- ---- |
|
||||
result | ==== = ==== |
|
||||
|
||||
A JavaScript version used to exist in aw-webui but was removed in `this PR <https://github.com/ActivityWatch/aw-webui/pull/48>`_.
|
||||
"""
|
||||
|
||||
events = sorted(events)
|
||||
filterevents = sorted(filterevents)
|
||||
|
||||
return [
|
||||
_replace_event_period(e1, ip)
|
||||
for (e1, _, ip) in _intersecting_eventpairs(events, filterevents)
|
||||
]
|
||||
|
||||
|
||||
def period_union(events1: List[Event], events2: List[Event]) -> List[Event]:
|
||||
"""
|
||||
Takes a list of two events and returns a new list of events covering the union
|
||||
of the timeperiods contained in the eventlists with no overlapping events.
|
||||
|
||||
.. warning:: This function strips all data from events as it cannot keep it consistent.
|
||||
|
||||
Example:
|
||||
.. code-block:: none
|
||||
|
||||
events1 | ------- --------- |
|
||||
events2 | ------ --- -- ---- |
|
||||
result | ----------- -- --------- |
|
||||
"""
|
||||
events = sorted(events1 + events2)
|
||||
merged_events = []
|
||||
if events:
|
||||
merged_events.append(events.pop(0))
|
||||
for e in events:
|
||||
last_event = merged_events[-1]
|
||||
|
||||
e_p = _get_event_period(e)
|
||||
le_p = _get_event_period(last_event)
|
||||
|
||||
if not e_p.gap(le_p):
|
||||
new_period = e_p.union(le_p)
|
||||
merged_events[-1] = _replace_event_period(last_event, new_period)
|
||||
else:
|
||||
merged_events.append(e)
|
||||
for event in merged_events:
|
||||
# Clear data
|
||||
event.data = {}
|
||||
return merged_events
|
||||
|
||||
|
||||
def union(events1: List[Event], events2: List[Event]) -> List[Event]:
|
||||
"""
|
||||
Concatenates and sorts union of 2 event lists and removes duplicates.
|
||||
|
||||
Example:
|
||||
Merges events from a backup-bucket with events from a "living" bucket.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
events = union(events_backup, events_living)
|
||||
"""
|
||||
|
||||
events1 = sorted(events1, key=lambda e: (e.timestamp, e.duration))
|
||||
events2 = sorted(events2, key=lambda e: (e.timestamp, e.duration))
|
||||
events_union = []
|
||||
|
||||
e1_i = 0
|
||||
e2_i = 0
|
||||
while e1_i < len(events1) and e2_i < len(events2):
|
||||
e1 = events1[e1_i]
|
||||
e2 = events2[e2_i]
|
||||
|
||||
if e1 == e2:
|
||||
events_union.append(e1)
|
||||
e1_i += 1
|
||||
e2_i += 1
|
||||
else:
|
||||
if e1.timestamp < e2.timestamp:
|
||||
events_union.append(e1)
|
||||
e1_i += 1
|
||||
elif e1.timestamp > e2.timestamp:
|
||||
events_union.append(e2)
|
||||
e2_i += 1
|
||||
elif e1.duration < e2.duration:
|
||||
events_union.append(e1)
|
||||
e1_i += 1
|
||||
else:
|
||||
events_union.append(e2)
|
||||
e2_i += 1
|
||||
|
||||
if e1_i < len(events1):
|
||||
events_union.extend(events1[e1_i:])
|
||||
|
||||
if e2_i < len(events2):
|
||||
events_union.extend(events2[e2_i:])
|
||||
|
||||
return events_union
|
||||
121
aw_transform/flood.py
Normal file
121
aw_transform/flood.py
Normal file
@@ -0,0 +1,121 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from copy import deepcopy
|
||||
from typing import List
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def flood(events: List[Event], pulsetime: float = 5) -> List[Event]:
|
||||
"""Fill short gaps between events and merge nearby equal-data events.
|
||||
|
||||
Events are ordered by timestamp and duration. Gaps no larger than
|
||||
``pulsetime`` are filled. Equal-data neighbours merge across the gap; when
|
||||
their data differs, both events extend to the midpoint. Splitting an
|
||||
uncertain interval evenly is independent of the surrounding event lengths
|
||||
and matches the Rust implementation.
|
||||
|
||||
Overlapping equal-data events merge. For overlapping differing-data events,
|
||||
final normalization gives the later event precedence so the result never
|
||||
double-counts time.
|
||||
|
||||
See https://github.com/ActivityWatch/activitywatch/issues/124 for the data
|
||||
collection uncertainty that flooding is intended to handle.
|
||||
"""
|
||||
# Originally written in aw-research: https://github.com/ActivityWatch/aw-analysis/blob/7da1f2cd8552f866f643501de633d74cdecab168/aw_analysis/flood.py
|
||||
# NOTE: This algorithm has a lot of smaller details that need to be
|
||||
# carefully considered by anyone wishing to edit it, see:
|
||||
# - https://github.com/ActivityWatch/aw-core/pull/73
|
||||
|
||||
events = deepcopy(events)
|
||||
# Sort by (timestamp, duration) so shorter same-timestamp events come first.
|
||||
# This matches aw-server-rust's sort_by_timestamp behavior and ensures
|
||||
# a well-defined processing order when events share the same timestamp.
|
||||
events = sorted(events, key=lambda e: (e.timestamp, e.duration))
|
||||
|
||||
# If negative gaps are smaller than this, prune them to become zero
|
||||
negative_gap_trim_thres = timedelta(seconds=0.1)
|
||||
|
||||
warned_about_negative_gap_safe = False
|
||||
warned_about_negative_gap_unsafe = False
|
||||
|
||||
for e1, e2 in zip(events[:-1], events[1:]):
|
||||
gap = e2.timestamp - (e1.timestamp + e1.duration)
|
||||
|
||||
if not gap:
|
||||
continue
|
||||
|
||||
# Sanity check in case events overlap
|
||||
if gap < timedelta(0) and e1.data == e2.data:
|
||||
# Events with negative gap but same data can safely be merged
|
||||
start = min(e1.timestamp, e2.timestamp)
|
||||
end = max(e1.timestamp + e1.duration, e2.timestamp + e2.duration)
|
||||
e1.timestamp, e1.duration = start, (end - start)
|
||||
e2.timestamp, e2.duration = end, timedelta(0)
|
||||
if not warned_about_negative_gap_safe:
|
||||
logger.warning(
|
||||
f"Gap was of negative duration but could be safely merged ({gap.total_seconds()}s). This message will only show once per batch."
|
||||
)
|
||||
warned_about_negative_gap_safe = True
|
||||
elif gap < -negative_gap_trim_thres and not warned_about_negative_gap_unsafe:
|
||||
# Events with negative gap but differing data cannot be merged safely
|
||||
logger.warning(
|
||||
f"Gap was of negative duration and could NOT be safely merged ({gap.total_seconds()}s). This warning will only show once per batch."
|
||||
)
|
||||
warned_about_negative_gap_unsafe = True
|
||||
# logger.warning("Event 1 (id {}): {} {}".format(e1.id, e1.timestamp, e1.duration))
|
||||
# logger.warning("Event 2 (id {}): {} {}".format(e2.id, e2.timestamp, e2.duration))
|
||||
elif -negative_gap_trim_thres < gap <= timedelta(seconds=pulsetime):
|
||||
e2_end = e2.timestamp + e2.duration
|
||||
|
||||
if e1.data == e2.data:
|
||||
# Preserve the longer neighbour's extent while merging across
|
||||
# the gap, matching the existing semantics for equal data.
|
||||
if e1.duration >= e2.duration:
|
||||
e1.duration = e2_end - e1.timestamp
|
||||
e2.timestamp = e2_end
|
||||
e2.duration = timedelta(0)
|
||||
else:
|
||||
e2.timestamp = e1.timestamp
|
||||
e2.duration = e2_end - e2.timestamp
|
||||
e1.duration = timedelta(0)
|
||||
else:
|
||||
# The gap is an interval of uncertainty: without evidence that
|
||||
# either neighbour owns more of it, split it at the midpoint.
|
||||
midpoint = e1.timestamp + e1.duration + gap / 2
|
||||
e1.duration = midpoint - e1.timestamp
|
||||
e2.timestamp = midpoint
|
||||
e2.duration = e2_end - midpoint
|
||||
|
||||
# Pairwise flooding can mutate an event after its previous pair has already
|
||||
# been processed. Normalize the final stream so downstream consumers never
|
||||
# double-count overlapping time. For differing data, the later event wins.
|
||||
normalized: List[Event] = []
|
||||
for event in (e for e in events if e.duration > timedelta(0)):
|
||||
while (
|
||||
normalized
|
||||
and normalized[-1].timestamp + normalized[-1].duration > event.timestamp
|
||||
):
|
||||
previous = normalized[-1]
|
||||
|
||||
if previous.data == event.data:
|
||||
end = max(
|
||||
previous.timestamp + previous.duration,
|
||||
event.timestamp + event.duration,
|
||||
)
|
||||
previous.duration = end - previous.timestamp
|
||||
break
|
||||
|
||||
previous.duration = event.timestamp - previous.timestamp
|
||||
if previous.duration <= timedelta(0):
|
||||
normalized.pop()
|
||||
continue
|
||||
|
||||
normalized.append(event)
|
||||
break
|
||||
else:
|
||||
normalized.append(event)
|
||||
|
||||
return normalized
|
||||
56
aw_transform/heartbeats.py
Normal file
56
aw_transform/heartbeats.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import List, Optional
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def heartbeat_reduce(events: List[Event], pulsetime: float) -> List[Event]:
|
||||
"""Merges consecutive events together according to the rules of `heartbeat_merge`."""
|
||||
reduced = []
|
||||
if events:
|
||||
reduced.append(events.pop(0))
|
||||
for heartbeat in events:
|
||||
merged = heartbeat_merge(reduced[-1], heartbeat, pulsetime)
|
||||
if merged is not None:
|
||||
# Heartbeat was merged
|
||||
reduced[-1] = merged
|
||||
else:
|
||||
# Heartbeat was not merged
|
||||
reduced.append(heartbeat)
|
||||
return reduced
|
||||
|
||||
|
||||
def heartbeat_merge(
|
||||
last_event: Event, heartbeat: Event, pulsetime: float
|
||||
) -> Optional[Event]:
|
||||
"""
|
||||
Merges two events if they have identical data
|
||||
and the heartbeat timestamp is within the pulsetime window.
|
||||
"""
|
||||
if last_event.data == heartbeat.data:
|
||||
# Seconds between end of last_event and start of heartbeat
|
||||
pulseperiod_end = (
|
||||
last_event.timestamp + last_event.duration + timedelta(seconds=pulsetime)
|
||||
)
|
||||
within_pulsetime_window = (
|
||||
last_event.timestamp <= heartbeat.timestamp <= pulseperiod_end
|
||||
)
|
||||
|
||||
if within_pulsetime_window:
|
||||
# Seconds between end of last_event and start of timestamp
|
||||
new_duration = (
|
||||
heartbeat.timestamp - last_event.timestamp
|
||||
) + heartbeat.duration
|
||||
if last_event.duration < timedelta(0):
|
||||
logger.warning(
|
||||
"Merging heartbeats would result in a negative duration, refusing to merge."
|
||||
)
|
||||
else:
|
||||
# Taking the max of durations ensures heartbeats that end before the last event don't shorten it
|
||||
last_event.duration = max((last_event.duration, new_duration))
|
||||
return last_event
|
||||
|
||||
return None
|
||||
40
aw_transform/merge_events_by_keys.py
Normal file
40
aw_transform/merge_events_by_keys.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import logging
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def merge_events_by_keys(events, keys) -> List[Event]:
|
||||
"""
|
||||
Sums the duration of all events which share a value for a key and returns a new event for each value.
|
||||
|
||||
.. note: The result will be a list of events without timestamp since they are merged.
|
||||
"""
|
||||
# Call recursively until all keys are consumed
|
||||
if len(keys) < 1:
|
||||
return events
|
||||
merged_events: Dict[Tuple, Event] = {}
|
||||
for event in events:
|
||||
composite_key: Tuple = ()
|
||||
for key in keys:
|
||||
if key in event.data:
|
||||
val = event["data"][key]
|
||||
# Needed for when the value is a list, such as for categories
|
||||
if isinstance(val, list):
|
||||
val = tuple(val)
|
||||
composite_key = composite_key + (val,)
|
||||
if composite_key not in merged_events:
|
||||
merged_events[composite_key] = Event(
|
||||
timestamp=event.timestamp, duration=event.duration, data={}
|
||||
)
|
||||
for key in keys:
|
||||
if key in event.data:
|
||||
merged_events[composite_key].data[key] = event.data[key]
|
||||
else:
|
||||
merged_events[composite_key].duration += event.duration
|
||||
result = []
|
||||
for key in merged_events:
|
||||
result.append(Event(**merged_events[key]))
|
||||
return result
|
||||
150
aw_transform/merge_subwatcher_fields.py
Normal file
150
aw_transform/merge_subwatcher_fields.py
Normal 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
|
||||
0
aw_transform/py.typed
Normal file
0
aw_transform/py.typed
Normal file
30
aw_transform/simplify.py
Normal file
30
aw_transform/simplify.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from typing import List
|
||||
|
||||
from aw_core import Event
|
||||
|
||||
|
||||
def simplify_string(events: List[Event], key: str = "title") -> List[Event]:
|
||||
events = deepcopy(events)
|
||||
|
||||
re_leadingdot = re.compile(r"^(●|\*)\s*")
|
||||
re_parensprefix = re.compile(r"^\([0-9]+\)\s*")
|
||||
re_fps = re.compile(r"FPS:\s+[0-9\.]+")
|
||||
|
||||
for e in events:
|
||||
# Remove prefixes that are numbers within parenthesis
|
||||
# Example: "(2) Facebook" -> "Facebook"
|
||||
# Example: "(1) YouTube" -> "YouTube"
|
||||
e.data[key] = re_parensprefix.sub("", e.data[key])
|
||||
|
||||
# Things generally specific to window events with the "app" key
|
||||
if key == "title" and "app" in e["data"]:
|
||||
# Remove FPS display in window title
|
||||
# Example: "Cemu - FPS: 59.2 - ..." -> "Cemu - FPS: ... - ..."
|
||||
e.data[key] = re_fps.sub("FPS: ...", e.data[key])
|
||||
|
||||
# For VSCode (uses ●), gedit (uses *), et al
|
||||
# See: https://github.com/ActivityWatch/aw-watcher-window/issues/32
|
||||
e.data[key] = re_leadingdot.sub("", e.data[key])
|
||||
return events
|
||||
32
aw_transform/sort_by.py
Normal file
32
aw_transform/sort_by.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_by_timestamp(events) -> List[Event]:
|
||||
"""Sorts a list of events by timestamp"""
|
||||
return sorted(events, key=lambda e: e.timestamp)
|
||||
|
||||
|
||||
def sort_by_duration(events) -> List[Event]:
|
||||
"""Sorts a list of events by duration"""
|
||||
return sorted(events, key=lambda e: e.duration, reverse=True)
|
||||
|
||||
|
||||
def limit_events(events, count) -> List[Event]:
|
||||
"""Returns the ``count`` first events in the list of events"""
|
||||
return events[:count]
|
||||
|
||||
|
||||
def sum_durations(events) -> timedelta:
|
||||
"""Sums the durations for the given events"""
|
||||
return timedelta(seconds=(sum(event.duration.total_seconds() for event in events)))
|
||||
|
||||
|
||||
def concat(events1, events2) -> List[Event]:
|
||||
"""Concatenates two lists of events"""
|
||||
events = events1 + events2
|
||||
return events
|
||||
32
aw_transform/split_url_events.py
Normal file
32
aw_transform/split_url_events.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aw_core.models import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def split_url_events(events: List[Event]) -> List[Event]:
|
||||
for event in events:
|
||||
if "url" in event.data:
|
||||
url = event.data["url"]
|
||||
parsed_url = urlparse(url)
|
||||
event.data["$protocol"] = parsed_url.scheme
|
||||
netloc = parsed_url.netloc
|
||||
if netloc:
|
||||
domain = netloc[4:] if netloc[:4] == "www." else netloc
|
||||
elif parsed_url.scheme:
|
||||
# For URLs without a domain (e.g. file://, about:),
|
||||
# use the scheme as domain so they don't all cluster as empty.
|
||||
domain = parsed_url.scheme
|
||||
else:
|
||||
domain = ""
|
||||
event.data["$domain"] = domain
|
||||
event.data["$path"] = parsed_url.path
|
||||
event.data["$params"] = parsed_url.params
|
||||
event.data["$options"] = parsed_url.query
|
||||
event.data["$identifier"] = parsed_url.fragment
|
||||
# TODO: Parse user, port etc aswell
|
||||
return events
|
||||
85
aw_transform/union_no_overlap.py
Normal file
85
aw_transform/union_no_overlap.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
Originally from aw-research
|
||||
"""
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import List, Tuple, Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from timeslot import Timeslot
|
||||
|
||||
from aw_core import Event
|
||||
|
||||
|
||||
def _split_event(e: Event, dt: datetime) -> Tuple[Event, Optional[Event]]:
|
||||
if e.timestamp < dt < e.timestamp + e.duration:
|
||||
e1 = deepcopy(e)
|
||||
e2 = deepcopy(e)
|
||||
e1.duration = dt - e.timestamp
|
||||
e2.timestamp = dt
|
||||
e2.duration = (e.timestamp + e.duration) - dt
|
||||
return (e1, e2)
|
||||
else:
|
||||
return (e, None)
|
||||
|
||||
|
||||
def test_split_event():
|
||||
now = datetime(2018, 1, 1, 0, 0).astimezone(timezone.utc)
|
||||
td1h = timedelta(hours=1)
|
||||
e = Event(timestamp=now, duration=2 * td1h, data={})
|
||||
e1, e2 = _split_event(e, now + td1h)
|
||||
assert e1.timestamp == now
|
||||
assert e1.duration == td1h
|
||||
assert e2
|
||||
assert e2.timestamp == now + td1h
|
||||
assert e2.duration == td1h
|
||||
|
||||
|
||||
def union_no_overlap(events1: List[Event], events2: List[Event]) -> List[Event]:
|
||||
"""Merges two eventlists and removes overlap, the first eventlist will have precedence
|
||||
|
||||
Example:
|
||||
events1 | xxx xx xxx |
|
||||
events1 | ---- ------ -- |
|
||||
result | xxx-- xx ----xxx -- |
|
||||
"""
|
||||
events1 = deepcopy(events1)
|
||||
events2 = deepcopy(events2)
|
||||
|
||||
# I looked a lot at aw_transform.union when I wrote this
|
||||
events_union = []
|
||||
e1_i = 0
|
||||
e2_i = 0
|
||||
while e1_i < len(events1) and e2_i < len(events2):
|
||||
e1 = events1[e1_i]
|
||||
e2 = events2[e2_i]
|
||||
e1_p = Timeslot(e1.timestamp, e1.timestamp + e1.duration)
|
||||
e2_p = Timeslot(e2.timestamp, e2.timestamp + e2.duration)
|
||||
|
||||
if e1_p.intersects(e2_p):
|
||||
if e1.timestamp <= e2.timestamp:
|
||||
events_union.append(e1)
|
||||
e1_i += 1
|
||||
|
||||
# If e2 continues after e1, we need to split up the event so we only get the part that comes after
|
||||
_, e2_next = _split_event(e2, e1.timestamp + e1.duration)
|
||||
if e2_next:
|
||||
events2[e2_i] = e2_next
|
||||
else:
|
||||
e2_i += 1
|
||||
else:
|
||||
e2_next, e2_next2 = _split_event(e2, e1.timestamp)
|
||||
events_union.append(e2_next)
|
||||
e2_i += 1
|
||||
if e2_next2:
|
||||
events2.insert(e2_i, e2_next2)
|
||||
else:
|
||||
if e1.timestamp <= e2.timestamp:
|
||||
events_union.append(e1)
|
||||
e1_i += 1
|
||||
else:
|
||||
events_union.append(e2)
|
||||
e2_i += 1
|
||||
events_union += events1[e1_i:]
|
||||
events_union += events2[e2_i:]
|
||||
return events_union
|
||||
Reference in New Issue
Block a user