33and send notifications to the user on predefined conditions.
44"""
55import logging
6- import os
76import threading
87from collections import defaultdict
98from datetime import datetime , timedelta , timezone
1918
2019import aw_client .queries
2120import click
22- import tomlkit
2321from desktop_notifier import DesktopNotifier
2422from typing_extensions import TypeAlias
2523
2624# TODO: Add thresholds for total time today (incl percentage of productive time)
2725
2826logger = logging .getLogger (__name__ )
29- TIME_OFFSET = timedelta (hours = 4 )
30- FALLBACK_CATEGORIES : list [tuple [list [str ], dict ]] = [
31- (
32- ["Work" ],
33- {
34- "type" : "regex" ,
35- "regex" : "Programming|nvim|taxes|Roam|Code" ,
36- },
37- ),
38- (
39- ["Twitter" ],
40- {
41- "type" : "regex" ,
42- "regex" : r"Twitter|twitter.com|Home / X" ,
43- },
44- ),
45- (
46- ["Youtube" ],
47- {
48- "type" : "regex" ,
49- "regex" : r"Youtube|youtube.com" ,
50- },
51- ),
52- ]
53-
54-
55- # TODO: move to aw-client utils
56- # TODO: Get categories from aw-webui export (in the future from server key-val store)
57- def load_category_toml (path : Path ) -> list [tuple [list [str ], dict ]]:
58- with open (path , "r" ) as f :
59- toml = tomlkit .load (f )
60- return parse_category_toml (toml , parent = [])
61-
62-
63- def parse_category_toml (toml : dict , parent : list [str ]) -> list [tuple [list [str ], dict ]]:
64- """
65- Parse category config file and return a list of categories.
66- """
67- categories = []
68- if "categories" in toml :
69- toml = toml ["categories" ]
70- for cat_name , cat in toml .items ():
71- if isinstance (cat , dict ):
72- categories += parse_category_toml (cat , parent = parent + [cat_name ])
73- else :
74- if cat_name == "$re" :
75- categories .append ((parent , {"type" : "regex" , "regex" : cat }))
76- else :
77- categories .append (
78- (parent + [cat_name ], {"type" : "regex" , "regex" : cat })
79- )
80- # create parent category with no rule if $re not given
81- if parent and parent not in (c for c , _ in categories ):
82- categories .append ((parent , {"type" : "none" }))
83- return sorted (categories )
84-
85-
86- def test_parse_category_toml ():
87- """
88- Test parsing of category config file.
89- """
90- # Example category config file:
91- config = """
92- [categories]
93- [categories.Media]
94- Music = '[Ss]potify|[Ss]ound[Cc]loud|Mixxx|Shazam'
95- [categories.Media.Games]
96- '$re' = 'Video Games'
97- Steam = 'Steam'
98- """
99- categories = parse_category_toml (tomlkit .loads (config ), parent = [])
100-
101- # Check that "Media" category exists
102- assert categories [0 ][0 ] == ["Media" ]
103-
104- # Check that "Games" category exists, and has the correct regex
105- assert categories [1 ][0 ] == ["Media" , "Games" ]
106- assert categories [1 ][1 ]["regex" ] == "Video Games"
107- assert categories [2 ][0 ] == ["Media" , "Games" , "Steam" ]
108- assert categories [2 ][1 ]["regex" ] == "Steam"
109-
11027
111- CATEGORIES = FALLBACK_CATEGORIES
112-
113-
114- time_offset = timedelta (hours = 4 )
28+ # TODO: read from server settings
29+ TIME_OFFSET = timedelta (hours = 4 )
11530
11631aw = aw_client .ActivityWatchClient ("aw-notify" , testing = False )
11732
@@ -155,8 +70,8 @@ def get_time(date=None) -> dict[str, timedelta]:
15570 date = date .replace (hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
15671 timeperiods = [
15772 (
158- date + time_offset ,
159- date + time_offset + timedelta (days = 1 ),
73+ date + TIME_OFFSET ,
74+ date + TIME_OFFSET + timedelta (days = 1 ),
16075 )
16176 ]
16277
@@ -165,7 +80,6 @@ def get_time(date=None) -> dict[str, timedelta]:
16580 aw_client .queries .DesktopQueryParams (
16681 bid_window = f"aw-watcher-window_{ hostname } " ,
16782 bid_afk = f"aw-watcher-afk_{ hostname } " ,
168- classes = CATEGORIES ,
16983 )
17084 )
17185 query = f"""
@@ -277,7 +191,7 @@ def time_to_next_threshold(self) -> timedelta:
277191 )
278192 if day_end < datetime .now (timezone .utc ):
279193 day_end += timedelta (days = 1 )
280- time_to_next_day = day_end - datetime .now (timezone .utc ) + time_offset
194+ time_to_next_day = day_end - datetime .now (timezone .utc ) + TIME_OFFSET
281195 return time_to_next_day + min (self .thresholds )
282196
283197 return min (self .thresholds_untriggered ) - self .time_spent
@@ -328,9 +242,10 @@ def test_category_alert():
328242 catalert .check ()
329243
330244
331- @click .group ()
245+ @click .group (invoke_without_command = True )
246+ @click .pass_context
332247@click .option ("-v" , "--verbose" , is_flag = True , help = "Enables verbose mode." )
333- def main (verbose : bool ):
248+ def main (ctx , verbose : bool ):
334249 logging .basicConfig (
335250 level = logging .DEBUG if verbose else logging .INFO ,
336251 format = "%(asctime)s [%(levelname)5s] %(message)s"
@@ -339,11 +254,8 @@ def main(verbose: bool):
339254 )
340255 logging .getLogger ("urllib3" ).setLevel (logging .WARNING )
341256
342- AW_CATEGORY_PATH = os .environ .get ("AW_CATEGORY_PATH" , None )
343- if AW_CATEGORY_PATH :
344- global CATEGORIES
345- CATEGORIES = load_category_toml (Path (AW_CATEGORY_PATH ))
346- logger .info ("Loaded categories from $AW_CATEGORY_PATH" )
257+ if ctx .invoked_subcommand is None :
258+ ctx .invoke (start )
347259
348260
349261@main .command ()
@@ -398,9 +310,8 @@ def send_checkin(title="Time today", date=None):
398310 Sends a summary notification of the day.
399311 Meant to be sent at a particular time, like at the end of a working day (e.g. 5pm).
400312 """
401- categories = list (
402- set (["All" , "Uncategorized" ] + [">" .join (k ) for k , _ in CATEGORIES ])
403- )
313+ # TODO: make configurable which categories to show
314+ categories = list (set (["All" , "Work" , "Uncategorized" ]))
404315 cat_time = get_time (date = date )
405316 time_spent = [cat_time .get (c , timedelta ()) for c in categories ]
406317 top_categories = [
0 commit comments