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

This commit is contained in:
upstream-mirror
2026-09-16 00:20:39 +05:00
commit 96755ecd21
36 changed files with 5338 additions and 0 deletions

97
examples/email_report.py Normal file
View 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()

View 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
View 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()

View 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()

View 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()

View 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()

View 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
View 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()

View 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)