snapshot of https://github.com/ActivityWatch/aw-client.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
Some checks failed
Build / Test on ubuntu-latest (push) Successful in 2m4s
Examples / Test examples (ubuntu-latest, 3.10) (push) Successful in 2m16s
Lint / lint (push) Successful in 1m25s
Build / Test on macOS-latest (push) Has been cancelled
Build / Test on windows-latest (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
This commit is contained in:
97
examples/email_report.py
Normal file
97
examples/email_report.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Sends an email with a summary report (WIP)
|
||||
|
||||
For now, it just sends stdin.
|
||||
In the future, it will generate a pretty email and send that.
|
||||
|
||||
Requires that an SMTP server is configured through environment variables.
|
||||
|
||||
Example usage:
|
||||
|
||||
$ env OUTPUT_HTML=true python3 examples/working_hours.py | python3 examples/email_report.py
|
||||
"""
|
||||
|
||||
import smtplib
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
|
||||
@dataclass
|
||||
class Recipient:
|
||||
name: str
|
||||
email: str
|
||||
|
||||
|
||||
def create_msg(
|
||||
sender: Recipient,
|
||||
receiver: Recipient,
|
||||
subject: str,
|
||||
text: str,
|
||||
html=None,
|
||||
) -> MIMEMultipart:
|
||||
"""Based on https://stackoverflow.com/a/882770/965332"""
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = sender.email
|
||||
msg["To"] = receiver.email
|
||||
|
||||
# Record the MIME types of both parts - text/plain and text/html.
|
||||
# Also attach parts into message container.
|
||||
# According to RFC 2046, the last part of a multipart message, in this case
|
||||
# the HTML message, is best and preferred.
|
||||
msg.attach(MIMEText(text, "plain"))
|
||||
if html:
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def main(read_stdin=True) -> None:
|
||||
smtp_server = os.environ["SMTP_SERVER"].strip()
|
||||
smtp_username = os.environ["SMTP_USERNAME"].strip()
|
||||
smtp_password = os.environ["SMTP_PASSWORD"].strip()
|
||||
|
||||
assert smtp_server, "Environment variable SMTP_SERVER not set"
|
||||
assert smtp_username, "Environment variable SMTP_USERNAME not set"
|
||||
assert smtp_password, "Environment variable SMTP_PASSWORD not set"
|
||||
|
||||
sender = Recipient("ActivityWatch (automated script)", "noreply@activitywatch.net")
|
||||
receiver = Recipient("Erik Bjäreholt", "erik.bjareholt@gmail.com")
|
||||
|
||||
if read_stdin:
|
||||
# Accepts input from stdin
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
text = "Just a test. ActivityWatch stats will go here."
|
||||
|
||||
text = text.replace("\n\n", "<hr>")
|
||||
# text = text.replace("\n\n", "<br>")
|
||||
# Create the body of the message (a plain-text and an HTML version).
|
||||
html = f"""\
|
||||
<html>
|
||||
<head></head>
|
||||
<body>
|
||||
<p>{text}</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
subject = "Example report from aw-client"
|
||||
msg = create_msg(sender, receiver, subject, text, html)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP_SSL(smtp_server, 465) as smtp:
|
||||
smtp.login(smtp_username, smtp_password)
|
||||
smtp.send_message(msg)
|
||||
smtp.quit()
|
||||
print("Successfully sent email")
|
||||
except smtplib.SMTPException as e:
|
||||
print("Error: unable to send email")
|
||||
print(e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
66
examples/load_dataframe.py
Normal file
66
examples/load_dataframe.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Load ActivityWatch data into a dataframe, and export as CSV.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import iso8601
|
||||
import pandas as pd
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_client.classes import default_classes
|
||||
from aw_client.queries import DesktopQueryParams, canonicalEvents
|
||||
|
||||
|
||||
def build_query() -> str:
|
||||
hostname = "fakedata" if os.getenv("CI") else socket.gethostname()
|
||||
canonicalQuery = canonicalEvents(
|
||||
DesktopQueryParams(
|
||||
bid_window=f"aw-watcher-window_{hostname}",
|
||||
bid_afk=f"aw-watcher-afk_{hostname}",
|
||||
classes=default_classes,
|
||||
)
|
||||
)
|
||||
return f"""
|
||||
{canonicalQuery}
|
||||
RETURN = {{"events": events}};
|
||||
"""
|
||||
|
||||
|
||||
def main() -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
td30d = timedelta(days=30)
|
||||
|
||||
aw = ActivityWatchClient()
|
||||
print("Querying...")
|
||||
query = build_query()
|
||||
data = aw.query(query, [(now - td30d, now)])
|
||||
|
||||
events = [
|
||||
{
|
||||
"timestamp": e["timestamp"],
|
||||
"duration": timedelta(seconds=e["duration"]),
|
||||
**e["data"],
|
||||
}
|
||||
for e in data[0]["events"]
|
||||
]
|
||||
|
||||
for e in events:
|
||||
e["$category"] = " > ".join(e["$category"])
|
||||
|
||||
df = pd.json_normalize(events)
|
||||
df["timestamp"] = pd.to_datetime(df["timestamp"].apply(iso8601.parse_date))
|
||||
df.set_index("timestamp", inplace=True)
|
||||
|
||||
print(df)
|
||||
|
||||
answer = input("Do you want to export to CSV? (y/N): ")
|
||||
if answer == "y":
|
||||
filename = "output.csv"
|
||||
df.to_csv(filename)
|
||||
print(f"Wrote to {filename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
67
examples/merge_buckets.py
Normal file
67
examples/merge_buckets.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import aw_client
|
||||
from aw_transform.filter_period_intersect import _intersecting_eventpairs
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Inserts all events from one bucket into another bucket, after checking for
|
||||
overlap (which you shouldn't have if it was caused by a changing hostname).
|
||||
|
||||
Useful to fix duplicate buckets caused by a changing hostname, as in this issue:
|
||||
https://github.com/ActivityWatch/activitywatch/issues/454
|
||||
"""
|
||||
|
||||
# You need to set testing=False if you're going to run this on your normal instance
|
||||
aw = aw_client.ActivityWatchClient(testing=True)
|
||||
|
||||
buckets = aw.get_buckets()
|
||||
print("Available bucket IDs:")
|
||||
print()
|
||||
for id in buckets.keys():
|
||||
print(id)
|
||||
print()
|
||||
|
||||
src_id = input("Source bucket ID: ")
|
||||
dest_id = input("Destination bucket ID: ")
|
||||
|
||||
src_events = aw.get_events(src_id)
|
||||
print(f"✓ src events: {len(src_events)}")
|
||||
dest_events = aw.get_events(dest_id)
|
||||
print(f"✓ dest events: {len(dest_events)}")
|
||||
|
||||
print("Checking overlap...")
|
||||
overlaps = list(_intersecting_eventpairs(src_events, dest_events))
|
||||
if overlaps:
|
||||
total_duration_src = sum((e.duration for e in src_events), timedelta())
|
||||
total_overlap = sum((tp.duration for _, _, tp in overlaps), timedelta())
|
||||
print(
|
||||
f"Buckets had overlap ({total_overlap} out of {total_duration_src}), can't safely merge, exiting."
|
||||
)
|
||||
exit(1)
|
||||
print("No overlap detected, continuing...")
|
||||
|
||||
print("You want to merge these two buckets:")
|
||||
print(f" - {src_id}")
|
||||
print(f" - {dest_id}")
|
||||
print(
|
||||
"WARNING: you should backup/export the buckets before attempting this operation"
|
||||
)
|
||||
if input("Does that look right? (y/n): ") != "y":
|
||||
print("Aborting")
|
||||
exit(1)
|
||||
|
||||
print("Inserting source events into destination bucket...")
|
||||
aw.insert_events(dest_id, src_events)
|
||||
|
||||
print("Operation complete")
|
||||
if input("Do you want to delete the source bucket? (y/n): ") == "y":
|
||||
aw.delete_bucket(src_id, force=True)
|
||||
print("Bucket deleted")
|
||||
|
||||
print("Exiting")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
examples/redact_sensitive.py
Normal file
147
examples/redact_sensitive.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""
|
||||
Interactive script to redact sensitive data.
|
||||
Be careful to not delete stuff you want to keep!
|
||||
|
||||
Issues/improvements:
|
||||
- If an event matches the sensitive string, only the sensitive field will be redacted (so if the title matches but not the URL, the URL will remain unredacted)
|
||||
- One might not want to redact to the non-informative 'REDACTED', but instead to a string with secret meaning.
|
||||
- No preview of the events/strings to be redacted.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from copy import deepcopy
|
||||
from typing import (
|
||||
List,
|
||||
Pattern,
|
||||
Set,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
|
||||
from aw_client import ActivityWatchClient
|
||||
from aw_core import Event
|
||||
|
||||
aw: ActivityWatchClient
|
||||
|
||||
REDACTED = "REDACTED"
|
||||
DRYRUN = True
|
||||
|
||||
|
||||
def main():
|
||||
global DRYRUN
|
||||
if "--wet" in sys.argv:
|
||||
DRYRUN = False
|
||||
|
||||
global aw
|
||||
aw = ActivityWatchClient(testing=True)
|
||||
|
||||
buckets = aw.get_buckets()
|
||||
print("Buckets: ")
|
||||
print("\n".join([" - " + bid for bid in buckets.keys()]) + "\n")
|
||||
|
||||
bid_to_redact = input(
|
||||
"In which bucket are the events you want to redact? (* for all): "
|
||||
)
|
||||
assert bid_to_redact == "*" or bid_to_redact in buckets, "Not a valid option"
|
||||
|
||||
regex_or_string = input(
|
||||
"Do you want to search by regex or string? (regex/string): "
|
||||
)
|
||||
assert regex_or_string in ["regex", "string"], "Not a valid option"
|
||||
|
||||
print("\nNOTE: Matching is not case sensitive!")
|
||||
pattern: Union[str, Pattern]
|
||||
if regex_or_string == "string":
|
||||
pattern = input("Enter a string indicating sensitive content: ").lower()
|
||||
else:
|
||||
pattern = re.compile(
|
||||
input("Enter a regex indicating sensitive content: ").lower()
|
||||
)
|
||||
|
||||
print("")
|
||||
if DRYRUN:
|
||||
print(
|
||||
"NOTE: Performing a dry run, no events will be modified. Run with --wet to modify events."
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"WARNING: Note that this performs an operation that cannot be undone. We strongly recommend that you backup/export your data before proceeding."
|
||||
)
|
||||
input("Press ENTER to continue, or Ctrl-C to abort")
|
||||
|
||||
if bid_to_redact == "*":
|
||||
for bucket_id in buckets.keys():
|
||||
if bucket_id.startswith("aw-watcher-afk"):
|
||||
continue
|
||||
_redact_bucket(bucket_id, pattern)
|
||||
else:
|
||||
_redact_bucket(bid_to_redact, pattern)
|
||||
|
||||
|
||||
def _redact_bucket(bucket_id: str, pattern: Union[str, Pattern]):
|
||||
print(f"\nChecking bucket: {bucket_id}")
|
||||
|
||||
global aw
|
||||
events = aw.get_events(bucket_id, limit=-1)
|
||||
sensitive_ids = _find_sensitive(events, pattern)
|
||||
print(f"Found {len(sensitive_ids)} sensitive events")
|
||||
|
||||
if not sensitive_ids:
|
||||
return
|
||||
|
||||
yes_redact = input(
|
||||
f"Do you want to replace all the matching strings with '{REDACTED}'? (y/N): "
|
||||
)
|
||||
if yes_redact == "y":
|
||||
for e in events:
|
||||
if e.id in sensitive_ids:
|
||||
e_before = e
|
||||
e = _redact_event(e, pattern)
|
||||
print(f"\nData before: {e_before.data}")
|
||||
print(f"Data after: {e.data}")
|
||||
|
||||
if DRYRUN:
|
||||
print("DRYRUN, would do: aw.insert_event(bucket_id, e)")
|
||||
else:
|
||||
aw.delete_event(bucket_id, cast(int, e_before.id))
|
||||
aw.insert_event(bucket_id, e)
|
||||
print("Redacted event")
|
||||
|
||||
|
||||
def _check_event(e: Event, pattern: Union[str, Pattern]) -> bool:
|
||||
for v in e.data.values():
|
||||
if isinstance(v, str):
|
||||
if isinstance(pattern, str):
|
||||
if pattern in v.lower():
|
||||
return True
|
||||
else:
|
||||
if pattern.findall(v.lower()):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _redact_event(e: Event, pattern: Union[str, Pattern]) -> Event:
|
||||
e = deepcopy(e)
|
||||
for k, v in e.data.items():
|
||||
if isinstance(v, str):
|
||||
if isinstance(pattern, str):
|
||||
if pattern in v.lower():
|
||||
e.data[k] = REDACTED
|
||||
else:
|
||||
if pattern.findall(v.lower()):
|
||||
e.data[k] = REDACTED
|
||||
return e
|
||||
|
||||
|
||||
def _find_sensitive(el: List[Event], pattern: Union[str, Pattern]) -> Set:
|
||||
sensitive_ids = set()
|
||||
for e in el:
|
||||
if _check_event(e, pattern):
|
||||
sensitive_ids.add(e.id)
|
||||
|
||||
return sensitive_ids
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
88
examples/suggest_categories.py
Normal file
88
examples/suggest_categories.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""
|
||||
Lists the most common words among uncategorized events, by duration, to help in creating categories.
|
||||
|
||||
This might make more sense as a notebook.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from tabulate import tabulate
|
||||
from typing import Dict, List, Tuple, Any
|
||||
|
||||
from aw_core import Event
|
||||
import aw_client
|
||||
from aw_client import queries
|
||||
|
||||
# set up client
|
||||
awc = aw_client.ActivityWatchClient("test")
|
||||
|
||||
|
||||
def example_categories():
|
||||
# TODO: Use tools in aw-research to load categories from toml file
|
||||
return [
|
||||
(
|
||||
("Work", "ActivityWatch"),
|
||||
{"type": "regex", "regex": "aw-|activitywatch", "ignore_case": True},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def get_events(categories=List[Tuple[Tuple[str], Dict[str, Any]]]):
|
||||
"""
|
||||
Retrieves AFK-filtered events, only returns events which are Uncategorized.
|
||||
"""
|
||||
|
||||
start = datetime(2022, 1, 1, tzinfo=timezone.utc)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
timeperiods = [(start, now)]
|
||||
|
||||
canonicalQuery = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window="aw-watcher-window_",
|
||||
bid_afk="aw-watcher-afk_",
|
||||
classes=categories,
|
||||
)
|
||||
)
|
||||
res = awc.query(
|
||||
f"""
|
||||
{canonicalQuery}
|
||||
events = filter_keyvals(events, "$category", [["Uncategorized"]]);
|
||||
duration = sum_durations(events);
|
||||
RETURN = {{"events": events, "duration": duration}};
|
||||
""",
|
||||
timeperiods,
|
||||
)
|
||||
events = res[0]["events"]
|
||||
print(f"Fetched {len(events)} events")
|
||||
return [Event(**e) for e in events]
|
||||
|
||||
|
||||
def events2words(events):
|
||||
for e in events:
|
||||
for v in e.data.values():
|
||||
if isinstance(v, str):
|
||||
for word in v.split():
|
||||
if len(word) >= 3:
|
||||
# normalize
|
||||
word = word.lower()
|
||||
yield (word, e.duration)
|
||||
|
||||
|
||||
def main():
|
||||
categories = example_categories()
|
||||
events = get_events(categories)
|
||||
|
||||
# find most common words, by duration
|
||||
corpus: Dict[str, timedelta] = Counter() # type: ignore
|
||||
for word, duration in events2words(events):
|
||||
if word not in corpus:
|
||||
corpus[word] = timedelta(0)
|
||||
corpus[word] += duration
|
||||
|
||||
# The top words are rarely useful for categorization, as they are usually browsers and other categories
|
||||
# of activity which are too broad for it to make sense as a rule (except as a fallback).
|
||||
print(tabulate(corpus.most_common(50), headers=["word", "duration"])) # type: ignore
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
213
examples/suggest_categories_gpt.py
Normal file
213
examples/suggest_categories_gpt.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Uses GPT-3 to suggest new categories.
|
||||
|
||||
Builds on suggest_categories.py for basic operations, like getting events.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from aw_core import Event
|
||||
from aw_transform.classify import Rule, categorize
|
||||
|
||||
from suggest_categories import example_categories, get_events
|
||||
|
||||
Category = Tuple[List[str], Dict[str, Any]]
|
||||
|
||||
|
||||
def prompt_preamble(categories: List[Category]) -> str:
|
||||
categories_str = "\n\n".join(
|
||||
[
|
||||
f" - Category: {' > '.join(name)}\n Regex: {rule['regex']}"
|
||||
for name, rule in categories
|
||||
]
|
||||
)
|
||||
|
||||
prompt = f"""We will classify window titles into user-defined categories defined by regular expressions.
|
||||
If a suitable one doesn't exists, we will create one with a suitable regex.
|
||||
|
||||
Existing categories:
|
||||
|
||||
{categories_str}
|
||||
|
||||
---
|
||||
|
||||
What category should "ActivityWatch - wwww.github.com" be in?
|
||||
Category: Work > ActivityWatch
|
||||
|
||||
What category should "reddit: the front page of the internet" be in?
|
||||
New Category: Media > Social > Reddit
|
||||
Regex: reddit
|
||||
|
||||
What category should "Twitter" be in?
|
||||
New Category: Media > Social > Twitter
|
||||
Regex: Twitter
|
||||
|
||||
What category should "Demis Hassabis: DeepMind - AI, Superintelligence & the Future of Humanity | Lex Fridman Podcast - YouTube - Mozilla Firefox" be in?
|
||||
New Category: Media > Video > YouTube
|
||||
Regex: YouTube
|
||||
|
||||
What category should "Tweetdeck" be in?
|
||||
Modify Category: Media > Social > Twitter
|
||||
Append Regex: Tweetdeck
|
||||
|
||||
What category should "cloudflare-ipfs.com | 524: A timeout occurred - cloudflare-ipfs.com - Mozilla Firefox" be in?
|
||||
Skip: No suitable category found or to suggest, best left as uncategorized.
|
||||
|
||||
What category should "Mozilla Firefox" be in?
|
||||
Skip: No suitable category found or to suggest, best left as uncategorized.
|
||||
|
||||
What category should "RimWorld" be in?
|
||||
New Category: Games > RimWorld
|
||||
Regex: RimWorld
|
||||
|
||||
What category should "Minecraft" be in?
|
||||
New Category: Games > Minecraft
|
||||
Regex: Minecraft
|
||||
|
||||
What category should "Free Porn Videos & Sex Movies - Porno, XXX, Porn Tube | Pornhub — Mozilla Firefox" be in?
|
||||
New Category: Media > Porn
|
||||
Regex: Pornhub"""
|
||||
|
||||
return prompt
|
||||
|
||||
|
||||
def process_prompt(prompt, categories, quiet=False) -> List[Category]:
|
||||
"""processes the prompt preamble for categories created/modified in the prompt"""
|
||||
for entry in prompt.split("---", 1)[1].split("\n\n"):
|
||||
if not entry.strip():
|
||||
continue
|
||||
# FIXME: Will break if string contains double-quotes
|
||||
title = entry.split("\n", 1)[0].split('"', 2)[1]
|
||||
response = entry.strip().split("\n", 1)[1]
|
||||
categories = parse_gpt_response(response, categories, title=title, quiet=quiet)
|
||||
return categories
|
||||
|
||||
|
||||
def gpt_suggest(event: Event, categories: List[Category]) -> List[Category]:
|
||||
"""
|
||||
Use OpenAI GPT-3 to suggest new categories.
|
||||
|
||||
Takes an uncategorized event and the current categories, and returns a list of suggested categories.
|
||||
"""
|
||||
import os
|
||||
import openai
|
||||
from copy import deepcopy
|
||||
|
||||
openai.api_key = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
title = event.data["title"]
|
||||
categories = deepcopy(categories)
|
||||
|
||||
prompt = prompt_preamble(categories)
|
||||
categories = process_prompt(prompt, categories, quiet=True)
|
||||
|
||||
prompt = f"""{prompt}
|
||||
|
||||
What category should "{title}" be in?"""
|
||||
|
||||
response = openai.Completion.create(
|
||||
model="text-davinci-002",
|
||||
prompt=prompt,
|
||||
temperature=0,
|
||||
max_tokens=64,
|
||||
top_p=1.0,
|
||||
frequency_penalty=0.0,
|
||||
presence_penalty=0.0,
|
||||
)
|
||||
text = response["choices"][0]["text"]
|
||||
print("-" * 80)
|
||||
# print(prompt + text)
|
||||
# print("> " + text.strip().replace("\n", "\n> "))
|
||||
|
||||
return parse_gpt_response(text, categories, title)
|
||||
|
||||
|
||||
def check_is_category(text: str, category: Category) -> bool:
|
||||
name, rule = category
|
||||
event = categorize(
|
||||
[Event(timestamp=datetime.now(tz=timezone.utc), data={"title": text})],
|
||||
[(list(name), Rule(rule))],
|
||||
)[0]
|
||||
return event.data["$category"] == list(category[0])
|
||||
|
||||
|
||||
def parse_gpt_response(text: str, categories: List[Category], title=None, quiet=False):
|
||||
category_names = [tuple(name) for name, _ in categories]
|
||||
|
||||
line1, *lines = text.strip().split("\n")
|
||||
if line1.startswith("Category:"):
|
||||
# chose existing category
|
||||
cat_name = tuple(line1.split(":", 1)[1].strip().split(" > "))
|
||||
if not quiet:
|
||||
print(f"Chose existing category {cat_name} (title: {title})")
|
||||
if cat_name not in [tuple(name[: len(cat_name)]) for name in category_names]:
|
||||
print(f"No category named {cat_name} found, skipping")
|
||||
elif line1.startswith("New Category:"):
|
||||
line2 = lines[0]
|
||||
category = line1.split(":", 1)[1].strip().split(" > ")
|
||||
regex = line2.strip().split(":", 1)[1].strip()
|
||||
if not quiet:
|
||||
print(f"Added category {category} with regex {regex} (title: {title})")
|
||||
cat: Category = (
|
||||
category,
|
||||
{"type": "regex", "regex": regex, "ignore_case": True},
|
||||
)
|
||||
if title and not check_is_category(title, cat):
|
||||
print(
|
||||
f"Bad suggested regex '{cat[1]['regex']}'. Title '{title}' does not match category {category}."
|
||||
)
|
||||
else:
|
||||
categories.append(cat)
|
||||
elif line1.startswith("Modify Category:"):
|
||||
line2 = lines[0]
|
||||
category = line1.split(":", 1)[1].strip().split(" > ")
|
||||
assert line2.startswith("Append Regex:")
|
||||
regex = line2.strip().split(":", 1)[1].strip()
|
||||
# get existing category
|
||||
for name, rule in categories:
|
||||
if name == category:
|
||||
rule["regex"] += "|" + regex
|
||||
if not quiet:
|
||||
print(f"Appended {regex} to {category}")
|
||||
break
|
||||
elif line1.startswith("Skip:"):
|
||||
pass
|
||||
else:
|
||||
print(f"Unknown response: '{text.strip()}'")
|
||||
|
||||
return categories
|
||||
|
||||
|
||||
def main():
|
||||
categories = example_categories()
|
||||
events = get_events(categories)
|
||||
|
||||
events_by_dur = sorted(events, key=lambda e: e.duration, reverse=True)
|
||||
for event in events_by_dur[:100]:
|
||||
# re-categorize and skip event if it is already categorized by a new rule
|
||||
event, *_ = categorize(
|
||||
[event], [(list(name), Rule(rule)) for name, rule in categories]
|
||||
)
|
||||
if list(event.data["$category"]) != ["Uncategorized"]:
|
||||
continue
|
||||
|
||||
categories = gpt_suggest(event, categories)
|
||||
|
||||
|
||||
def test_parse_gpt_response():
|
||||
categories = example_categories()
|
||||
prompt = prompt_preamble(categories)
|
||||
categories = process_prompt(prompt, categories)
|
||||
|
||||
cat_twitter = list(
|
||||
(name, rule)
|
||||
for name, rule in categories
|
||||
if tuple(name) == ("Media", "Social", "Twitter")
|
||||
)
|
||||
assert len(cat_twitter) == 1
|
||||
assert cat_twitter[0][1]["regex"] == "Twitter|Tweetdeck"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
16
examples/time_spent_today.py
Normal file
16
examples/time_spent_today.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
import socket
|
||||
|
||||
import aw_client
|
||||
|
||||
if __name__ == "__main__":
|
||||
bucket_id = f"aw-watcher-afk_{socket.gethostname()}"
|
||||
|
||||
daystart = datetime.combine(datetime.now().date(), time()).astimezone(timezone.utc)
|
||||
dayend = daystart + timedelta(days=1)
|
||||
|
||||
awc = aw_client.ActivityWatchClient("testclient")
|
||||
events = awc.get_events(bucket_id, start=daystart, end=dayend)
|
||||
events = [e for e in events if e.data["status"] == "not-afk"]
|
||||
total_duration = sum((e.duration for e in events), timedelta())
|
||||
print(f"Total time spent on computer today: {total_duration}")
|
||||
157
examples/working_hours.py
Normal file
157
examples/working_hours.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Script that computes how many hours was spent in a regex-specified "work" category for each day in a given month.
|
||||
|
||||
Also saves the matching work-events to a JSON file (for auditing purposes).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import aw_client
|
||||
from aw_client import queries
|
||||
from aw_core import Event
|
||||
from aw_transform import flood
|
||||
from tabulate import tabulate
|
||||
|
||||
OUTPUT_HTML = os.environ.get("OUTPUT_HTML", "").lower() == "true"
|
||||
|
||||
td1d = timedelta(days=1)
|
||||
day_offset = timedelta(hours=4)
|
||||
|
||||
|
||||
def _pretty_timedelta(td: timedelta) -> str:
|
||||
s = str(td)
|
||||
s = re.sub(r"^(0+[:]?)+", "", s)
|
||||
s = s.rjust(len(str(td)), " ")
|
||||
s = re.sub(r"[.]\d+", "", s)
|
||||
return s
|
||||
|
||||
|
||||
assert _pretty_timedelta(timedelta(seconds=120)) == " 2:00"
|
||||
assert _pretty_timedelta(timedelta(hours=9, minutes=5)) == "9:05:00"
|
||||
|
||||
|
||||
def generous_approx(events: List[dict], max_break: float) -> timedelta:
|
||||
"""
|
||||
Returns a generous approximation of worked time by including non-categorized time when shorter than a specific duration
|
||||
|
||||
max_break: Max time (in seconds) to flood when there's an empty slot between events
|
||||
"""
|
||||
events_e: List[Event] = [Event(**e) for e in events]
|
||||
return sum(
|
||||
map(lambda e: e.duration, flood(events_e, max_break)),
|
||||
timedelta(),
|
||||
)
|
||||
|
||||
|
||||
def query(regex: str, timeperiods, hostname: str):
|
||||
print("Querying events...")
|
||||
print(f" Day offset: {day_offset}")
|
||||
print("")
|
||||
|
||||
categories: List[Tuple[List[str], Dict]] = [
|
||||
(
|
||||
["Work"],
|
||||
{
|
||||
"type": "regex",
|
||||
"regex": regex,
|
||||
"ignore_case": True,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
aw = aw_client.ActivityWatchClient(client_name="working_hours")
|
||||
|
||||
canonicalQuery = queries.canonicalEvents(
|
||||
queries.DesktopQueryParams(
|
||||
bid_window=f"aw-watcher-window_{hostname}",
|
||||
bid_afk=f"aw-watcher-afk_{hostname}",
|
||||
classes=categories,
|
||||
filter_classes=[["Work"]],
|
||||
)
|
||||
)
|
||||
query = f"""
|
||||
{canonicalQuery}
|
||||
duration = sum_durations(events);
|
||||
RETURN = {{"events": events, "duration": duration}};
|
||||
"""
|
||||
|
||||
res = aw.query(query, timeperiods)
|
||||
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python3 working_hours.py <regex> [hostname]")
|
||||
exit(1)
|
||||
|
||||
regex = sys.argv[1]
|
||||
print(f"Using regex: {regex}")
|
||||
|
||||
if len(sys.argv) > 2:
|
||||
hostname = sys.argv[2]
|
||||
print(f"Using hostname: {hostname}")
|
||||
else:
|
||||
hostname = socket.gethostname()
|
||||
|
||||
now = datetime.now().astimezone()
|
||||
today = (datetime.combine(now.date(), time()) + day_offset).astimezone()
|
||||
|
||||
timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(5)]
|
||||
timeperiods.reverse()
|
||||
|
||||
res = query(regex, timeperiods, hostname)
|
||||
|
||||
for break_time in [0, 5 * 60, 15 * 60]:
|
||||
_print(timeperiods, res, break_time, {"regex": regex})
|
||||
|
||||
fn = "working_hours_events.json"
|
||||
with open(fn, "w") as f:
|
||||
print(f"Saving to {fn}...")
|
||||
json.dump(res, f, indent=2)
|
||||
|
||||
|
||||
def _print(timeperiods, res, break_time, params: dict):
|
||||
print("Using:")
|
||||
print(f" break_time={break_time}")
|
||||
print("\n".join(f" {key}={val}" for key, val in params.items()))
|
||||
print(
|
||||
tabulate(
|
||||
[
|
||||
[
|
||||
start.date(),
|
||||
# Without flooding:
|
||||
# _pretty_timedelta(timedelta(seconds=res[i]["duration"])),
|
||||
# With flooding:
|
||||
_pretty_timedelta(generous_approx(res[i]["events"], break_time)),
|
||||
len(res[i]["events"]),
|
||||
]
|
||||
for i, (start, stop) in enumerate(timeperiods)
|
||||
],
|
||||
headers=["Date", "Duration", "Events"],
|
||||
colalign=(
|
||||
"left",
|
||||
"right",
|
||||
),
|
||||
tablefmt="html" if OUTPUT_HTML else "simple",
|
||||
)
|
||||
)
|
||||
|
||||
print(
|
||||
f"Total: {sum((generous_approx(res[i]['events'], break_time) for i in range(len(timeperiods))), timedelta())}"
|
||||
)
|
||||
print("")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# ignore log warnings in aw_transform
|
||||
logging.getLogger("aw_transform").setLevel(logging.ERROR)
|
||||
|
||||
main()
|
||||
129
examples/working_hours_gspread.py
Normal file
129
examples/working_hours_gspread.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
This script uses ActivityWatch events to updates a Google Sheet
|
||||
with the any events matching a regex for the last `days_back` days.
|
||||
|
||||
It uses the `working_hours.py` example to calculate the working hours
|
||||
and the `gspread` library to interact with Google Sheets.
|
||||
|
||||
The Google Sheet is identified by its key, which is hardcoded in the script.
|
||||
The script uses a service account for authentication with the Google Sheets API.
|
||||
|
||||
The script assumes that the Google Sheet has a worksheet for each hostname, named "worked-{hostname}".
|
||||
If such a worksheet does not exist, the script will fail.
|
||||
|
||||
The working hours are calculated generously, meaning that if the time between two consecutive
|
||||
events is less than `break_time` (10 minutes by default), it is considered as working time.
|
||||
|
||||
Usage:
|
||||
python3 working_hours_gspread.py <sheet_key> <regex>
|
||||
"""
|
||||
|
||||
import socket
|
||||
import sys
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import Union
|
||||
|
||||
import gspread
|
||||
from gspread.utils import ValueInputOption
|
||||
|
||||
import working_hours
|
||||
|
||||
td1d = timedelta(days=1)
|
||||
break_time = 10 * 60
|
||||
|
||||
|
||||
def update_sheet(sheet_key: str, regex: str):
|
||||
"""
|
||||
Update the Google Sheet with the working hours for the last `days_back` days.
|
||||
|
||||
1. Open the sheet and get the last entry
|
||||
2. Query the working hours for the days since the last entry
|
||||
3. Update the last entry in the Google Sheet (if any)
|
||||
4. Append any new entries
|
||||
"""
|
||||
|
||||
hostname = socket.gethostname()
|
||||
hostname_display = hostname.replace(".localdomain", "").replace(".local", "")
|
||||
|
||||
try:
|
||||
gc = gspread.service_account()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print(
|
||||
"Failed to authenticate with Google Sheets API.\n"
|
||||
"Make sure you have a service account key in ~/.config/gspread/service_account.json\n"
|
||||
"See https://gspread.readthedocs.io/en/latest/oauth2.html#for-bots-using-service-account"
|
||||
)
|
||||
exit(1)
|
||||
|
||||
# Open the sheet
|
||||
sh = gc.open_by_key(sheet_key)
|
||||
print(f"Updating document: {sh.title}")
|
||||
worksheet = sh.worksheet(f"worked-{hostname_display}")
|
||||
print(f"Updating worksheet: {worksheet.title}")
|
||||
|
||||
# Get the most recent entry from the Google Sheet
|
||||
values = worksheet.get_all_values()
|
||||
if values:
|
||||
last_row = values[-1]
|
||||
last_date = datetime.strptime(last_row[0], "%Y-%m-%d").date()
|
||||
else:
|
||||
last_date = None
|
||||
|
||||
last_datetime = (
|
||||
(datetime.combine(last_date, time()) + working_hours.day_offset).astimezone()
|
||||
if last_date
|
||||
else None
|
||||
)
|
||||
|
||||
if last_datetime:
|
||||
print(f"Last entry: {last_datetime}")
|
||||
|
||||
now = datetime.now().astimezone()
|
||||
today = (
|
||||
datetime.combine(now.date(), time()) + working_hours.day_offset
|
||||
).astimezone()
|
||||
|
||||
# Create a list of time periods to query, from last_date or days_back_on_new back if None
|
||||
days_back_on_new = 30
|
||||
days_back = (today - last_datetime).days + 1 if last_datetime else days_back_on_new
|
||||
timeperiods = [(today - i * td1d, today - (i - 1) * td1d) for i in range(days_back)]
|
||||
timeperiods.reverse()
|
||||
|
||||
# Run the query function from the original script and get the result
|
||||
res = working_hours.query(regex, timeperiods, hostname)
|
||||
|
||||
# Iterate over the result and update or append the data to the Google Sheet
|
||||
for tp, r in zip(timeperiods, res):
|
||||
# skip if the timeperiod is in the future (might happen between midnight and the day_offset)
|
||||
if tp[0] > now:
|
||||
continue
|
||||
|
||||
date = tp[0].date()
|
||||
duration = (
|
||||
working_hours.generous_approx(r["events"], break_time).total_seconds()
|
||||
/ 3600
|
||||
)
|
||||
row: list[Union[str, float]] = [str(date), duration]
|
||||
|
||||
# If the date is the same as the last entry, update it
|
||||
if last_date and date == last_date:
|
||||
print(f"Updating {row}")
|
||||
worksheet.update_cell(len(worksheet.get_all_values()), 2, duration)
|
||||
# If the date is later than the last entry, append it
|
||||
elif not last_date or date > last_date:
|
||||
print(f"Appending {row}")
|
||||
worksheet.append_row(row, value_input_option=ValueInputOption.user_entered)
|
||||
else:
|
||||
print(f"Skipping {row}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3:
|
||||
sheet_key = sys.argv[1]
|
||||
regex = sys.argv[2]
|
||||
else:
|
||||
print("Usage: python3 working_hours_gspread.py <sheet_key> <regex>")
|
||||
exit(1)
|
||||
|
||||
update_sheet(sheet_key, regex)
|
||||
Reference in New Issue
Block a user