summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--accounts/gkleen@sif/default.nix2
-rw-r--r--accounts/gkleen@sif/niri/default.nix30
-rw-r--r--overlays/worktime/pyproject.toml2
-rw-r--r--overlays/worktime/uv.lock13
-rwxr-xr-xoverlays/worktime/worktime/__main__.py159
5 files changed, 192 insertions, 14 deletions
diff --git a/accounts/gkleen@sif/default.nix b/accounts/gkleen@sif/default.nix
index ac520249..041ffa9e 100644
--- a/accounts/gkleen@sif/default.nix
+++ b/accounts/gkleen@sif/default.nix
@@ -344,7 +344,7 @@ in {
344 font = "Fira Sans"; 344 font = "Fira Sans";
345 }; 345 };
346 colors = { 346 colors = {
347 background = "000000aa"; 347 background = "000000cc";
348 text = "cdd6f4ff"; 348 text = "cdd6f4ff";
349 match = "94e2d5ff"; 349 match = "94e2d5ff";
350 selection = "585b70ff"; 350 selection = "585b70ff";
diff --git a/accounts/gkleen@sif/niri/default.nix b/accounts/gkleen@sif/niri/default.nix
index bc4e24c8..9bd6eafd 100644
--- a/accounts/gkleen@sif/niri/default.nix
+++ b/accounts/gkleen@sif/niri/default.nix
@@ -134,7 +134,7 @@ let
134 134
135 windows_json="$(niri msg -j windows)" 135 windows_json="$(niri msg -j windows)"
136 active_workspace="$(jq -r '.[] | select(.is_focused) | .workspace_id' <<<"$windows_json")" 136 active_workspace="$(jq -r '.[] | select(.is_focused) | .workspace_id' <<<"$windows_json")"
137 window_ix="$(gojq -r --arg active_workspace "$active_workspace" '.[] | select('"$window_select"') | "\(.title)\u0000icon\u001f\(.app_id)"' <<<"$windows_json" | fuzzel --log-level=warning --dmenu --index)" 137 window_ix="$(gojq -r --arg active_workspace "$active_workspace" '.[] | select('"$window_select"') | "\(.title)\u0000icon\u001f\(.app_id)"' <<<"$windows_json" | fuzzel --width=60 --log-level=warning --dmenu --index)"
138 # shellcheck disable=SC2016 138 # shellcheck disable=SC2016
139 window_json="$(gojq -rc --arg active_workspace "$active_workspace" --arg window_ix "$window_ix" 'map(select('"$window_select"')) | .[($window_ix | tonumber)]' <<<"$windows_json")" 139 window_json="$(gojq -rc --arg active_workspace "$active_workspace" --arg window_ix "$window_ix" 'map(select('"$window_select"')) | .[($window_ix | tonumber)]' <<<"$windows_json")"
140 140
@@ -449,7 +449,7 @@ in {
449 { title = "^Access Request.*"; } 449 { title = "^Access Request.*"; }
450 { title = ".*Passkey credentials$"; } 450 { title = ".*Passkey credentials$"; }
451 ]; 451 ];
452 windowRuleExtra = [ 452 windowRuleExtra = with kdl; [
453 (kdl.leaf "open-focused" false) 453 (kdl.leaf "open-focused" false)
454 ]; 454 ];
455 key = "Mod+Control+P"; 455 key = "Mod+Control+P";
@@ -477,6 +477,20 @@ in {
477 app-id = "com.github.wwmm.easyeffects"; 477 app-id = "com.github.wwmm.easyeffects";
478 spawn = [ "easyeffects" ]; 478 spawn = [ "easyeffects" ];
479 } 479 }
480 { name = "time";
481 key = "Mod+Control+K";
482 app-id = "chrome-kimai.yggdrasil.li__-Default";
483 spawn = [ (toString (pkgs.resholve.writeScript "kimai" {
484 interpreter = pkgs.runtimeShell;
485 inputs = [ pkgs.dex ];
486 execer = [ "cannot:${lib.getExe pkgs.dex}" ];
487 } ''
488 exec dex $HOME/.local/state/nix/profile/share/applications/kimai.desktop
489 '')) ];
490 windowRuleExtra = with kdl; [
491 (leaf "block-out-from" "screencast")
492 ];
493 }
480 ]; 494 ];
481 programs.niri.config = 495 programs.niri.config =
482 let 496 let
@@ -683,12 +697,6 @@ in {
683 (leaf "open-on-workspace" "comm") 697 (leaf "open-on-workspace" "comm")
684 ]) 698 ])
685 (plain "window-rule" [ 699 (plain "window-rule" [
686 (leaf "match" { app-id = "^chrome-kimai\.yggdrasil\.li__-Default$"; })
687 (leaf "open-on-workspace" "comm")
688 (leaf "open-fullscreen" false)
689 (plain "default-column-width" [(leaf "proportion" (2. / 3.))])
690 ])
691 (plain "window-rule" [
692 (leaf "match" { app-id = "^firefox$"; }) 700 (leaf "match" { app-id = "^firefox$"; })
693 (leaf "open-on-workspace" "web") 701 (leaf "open-on-workspace" "web")
694 (leaf "open-maximized" true) 702 (leaf "open-maximized" true)
@@ -803,7 +811,7 @@ in {
803 done < <(export LC_ALL=C.UTF-8; echo; find "$RESULTS_DIR" -type f -printf $'%T@ %p\n' | sort -n | cut -d' ' -f2- | xargs -r cat) 811 done < <(export LC_ALL=C.UTF-8; echo; find "$RESULTS_DIR" -type f -printf $'%T@ %p\n' | sort -n | cut -d' ' -f2- | xargs -r cat)
804 $FOUND || echo 812 $FOUND || echo
805 } 813 }
806 FUZZEL_RES=$(prev | fuzzel --dmenu --prompt "qalc> ") || exit $? 814 FUZZEL_RES=$(prev | fuzzel --dmenu --prompt "qalc> " --width=60) || exit $?
807 if [[ "$FUZZEL_RES" =~ .*\ =\ .* ]]; then 815 if [[ "$FUZZEL_RES" =~ .*\ =\ .* ]]; then
808 QALC_RES="$FUZZEL_RES" 816 QALC_RES="$FUZZEL_RES"
809 QALC_RET=0 817 QALC_RET=0
@@ -832,7 +840,7 @@ in {
832 name = "emoji-fuzzel"; 840 name = "emoji-fuzzel";
833 runtimeInputs = with pkgs; [ config.programs.fuzzel.package wtype wl-clipboard-rs ]; 841 runtimeInputs = with pkgs; [ config.programs.fuzzel.package wtype wl-clipboard-rs ];
834 text = '' 842 text = ''
835 FUZZEL_RES=$(fuzzel --dmenu --prompt "emoji> " <"$HOME"/.local/share/emoji-data/list.txt) || exit $? 843 FUZZEL_RES=$(fuzzel --dmenu --prompt "emoji> " --cache "$HOME"/.cache/fuzzel-emoji --width=60 <"$HOME"/.local/share/emoji-data/list.txt) || exit $?
836 [[ -n "$FUZZEL_RES" ]] || exit 1 844 [[ -n "$FUZZEL_RES" ]] || exit 1
837 wl-copy "$(cut -d ':' -f 1 <<<"$FUZZEL_RES" | tr -d '\n')" && wtype -k XF86Paste 845 wl-copy "$(cut -d ':' -f 1 <<<"$FUZZEL_RES" | tr -d '\n')" && wtype -k XF86Paste
838 ''; 846 '';
@@ -973,6 +981,8 @@ in {
973 981
974 "Mod+D".action = with-urgent-window-action "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}"; 982 "Mod+D".action = with-urgent-window-action "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}";
975 "Mod+Shift+D".action = with-focused-window-action "{\"Action\":{\"UnsetUrgent\":{\"id\": .id}}}"; 983 "Mod+Shift+D".action = with-focused-window-action "{\"Action\":{\"UnsetUrgent\":{\"id\": .id}}}";
984
985 "Mod+K".action = spawn (lib.getExe' pkgs.worktime "worktime-ui");
976 })) 986 }))
977 (map ({ name, selector, spawn, key, ...}: if key != null && selector != null && spawn != null then bind key { action = focus-or-spawn-action selector name spawn; } else null) cfg.scratchspaces) 987 (map ({ name, selector, spawn, key, ...}: if key != null && selector != null && spawn != null then bind key { action = focus-or-spawn-action selector name spawn; } else null) cfg.scratchspaces)
978 (map ({ name, moveKey, ...}: if moveKey != null then bind moveKey { action = kdl.magic-leaf "move-column-to-workspace" name; } else null) cfg.scratchspaces) 988 (map ({ name, moveKey, ...}: if moveKey != null then bind moveKey { action = kdl.magic-leaf "move-column-to-workspace" name; } else null) cfg.scratchspaces)
diff --git a/overlays/worktime/pyproject.toml b/overlays/worktime/pyproject.toml
index bb6934ac..ccc0ff62 100644
--- a/overlays/worktime/pyproject.toml
+++ b/overlays/worktime/pyproject.toml
@@ -10,10 +10,12 @@ dependencies = [
10 "tabulate>=0.9.0,<0.10", 10 "tabulate>=0.9.0,<0.10",
11 "toml>=0.10.2,<0.11", 11 "toml>=0.10.2,<0.11",
12 "jsonpickle>=4.0.5,<5", 12 "jsonpickle>=4.0.5,<5",
13 "frozendict>=2.4.6",
13] 14]
14 15
15[project.scripts] 16[project.scripts]
16worktime = "worktime.__main__:main" 17worktime = "worktime.__main__:main"
18worktime-ui = "worktime.__main__:ui"
17 19
18[build-system] 20[build-system]
19requires = ["hatchling"] 21requires = ["hatchling"]
diff --git a/overlays/worktime/uv.lock b/overlays/worktime/uv.lock
index 8f91b44b..cffa0301 100644
--- a/overlays/worktime/uv.lock
+++ b/overlays/worktime/uv.lock
@@ -47,6 +47,17 @@ wheels = [
47] 47]
48 48
49[[package]] 49[[package]]
50name = "frozendict"
51version = "2.4.6"
52source = { registry = "https://pypi.org/simple" }
53sdist = { url = "https://files.pythonhosted.org/packages/bb/59/19eb300ba28e7547538bdf603f1c6c34793240a90e1a7b61b65d8517e35e/frozendict-2.4.6.tar.gz", hash = "sha256:df7cd16470fbd26fc4969a208efadc46319334eb97def1ddf48919b351192b8e", size = 316416, upload-time = "2024-10-13T12:15:32.449Z" }
54wheels = [
55 { url = "https://files.pythonhosted.org/packages/04/13/d9839089b900fa7b479cce495d62110cddc4bd5630a04d8469916c0e79c5/frozendict-2.4.6-py311-none-any.whl", hash = "sha256:d065db6a44db2e2375c23eac816f1a022feb2fa98cbb50df44a9e83700accbea", size = 16148, upload-time = "2024-10-13T12:15:26.839Z" },
56 { url = "https://files.pythonhosted.org/packages/ba/d0/d482c39cee2ab2978a892558cf130681d4574ea208e162da8958b31e9250/frozendict-2.4.6-py312-none-any.whl", hash = "sha256:49344abe90fb75f0f9fdefe6d4ef6d4894e640fadab71f11009d52ad97f370b9", size = 16146, upload-time = "2024-10-13T12:15:28.16Z" },
57 { url = "https://files.pythonhosted.org/packages/a5/8e/b6bf6a0de482d7d7d7a2aaac8fdc4a4d0bb24a809f5ddd422aa7060eb3d2/frozendict-2.4.6-py313-none-any.whl", hash = "sha256:7134a2bb95d4a16556bb5f2b9736dceb6ea848fa5b6f3f6c2d6dba93b44b4757", size = 16146, upload-time = "2024-10-13T12:15:29.495Z" },
58]
59
60[[package]]
50name = "idna" 61name = "idna"
51version = "3.10" 62version = "3.10"
52source = { registry = "https://pypi.org/simple" } 63source = { registry = "https://pypi.org/simple" }
@@ -150,6 +161,7 @@ name = "worktime"
150version = "1.0.0" 161version = "1.0.0"
151source = { editable = "." } 162source = { editable = "." }
152dependencies = [ 163dependencies = [
164 { name = "frozendict" },
153 { name = "jsonpickle" }, 165 { name = "jsonpickle" },
154 { name = "python-dateutil" }, 166 { name = "python-dateutil" },
155 { name = "pyxdg" }, 167 { name = "pyxdg" },
@@ -161,6 +173,7 @@ dependencies = [
161 173
162[package.metadata] 174[package.metadata]
163requires-dist = [ 175requires-dist = [
176 { name = "frozendict", specifier = ">=2.4.6" },
164 { name = "jsonpickle", specifier = ">=4.0.5,<5" }, 177 { name = "jsonpickle", specifier = ">=4.0.5,<5" },
165 { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, 178 { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" },
166 { name = "pyxdg", specifier = ">=0.28,<0.29" }, 179 { name = "pyxdg", specifier = ">=0.28,<0.29" },
diff --git a/overlays/worktime/worktime/__main__.py b/overlays/worktime/worktime/__main__.py
index dbe7502e..0bad8ac8 100755
--- a/overlays/worktime/worktime/__main__.py
+++ b/overlays/worktime/worktime/__main__.py
@@ -29,7 +29,7 @@ from sys import stderr, stdout
29 29
30from tabulate import tabulate 30from tabulate import tabulate
31 31
32from itertools import groupby, count 32from itertools import groupby, count, islice
33from functools import cache, partial 33from functools import cache, partial
34 34
35from pathlib import Path 35from pathlib import Path
@@ -42,6 +42,12 @@ import jsonpickle
42from hashlib import blake2s 42from hashlib import blake2s
43import json 43import json
44 44
45import asyncio
46
47from frozendict import frozendict
48from contextlib import closing
49import os
50
45class BearerAuth(requests.auth.AuthBase): 51class BearerAuth(requests.auth.AuthBase):
46 def __init__(self, token): 52 def __init__(self, token):
47 self.token = token 53 self.token = token
@@ -149,7 +155,7 @@ class KimaiAPI(object):
149 def get_billable_hours(self, start_date: datetime, end_date: datetime = datetime.now(timezone.utc)) -> timedelta: 155 def get_billable_hours(self, start_date: datetime, end_date: datetime = datetime.now(timezone.utc)) -> timedelta:
150 return sum(self.entry_durations(start_date, end_date=end_date), start=timedelta(milliseconds=0)) 156 return sum(self.entry_durations(start_date, end_date=end_date), start=timedelta(milliseconds=0))
151 157
152 def get_running_clock(self, now: datetime = datetime.now(timezone.utc)) -> timedelta | None: 158 def get_running_entry(self) -> Any | None:
153 kimai_entries = self._session.get('/api/timesheets/active').json() 159 kimai_entries = self._session.get('/api/timesheets/active').json()
154 if not kimai_entries: 160 if not kimai_entries:
155 return None 161 return None
@@ -158,8 +164,44 @@ class KimaiAPI(object):
158 if entry['project']['customer']['id'] not in self._client_ids: 164 if entry['project']['customer']['id'] not in self._client_ids:
159 return None 165 return None
160 166
167 return entry
168
169 def get_running_clock(self, now: datetime = datetime.now(timezone.utc)) -> timedelta | None:
170 entry = self.get_running_entry()
171 if not entry:
172 return None
161 start = isoparse(entry['begin']) 173 start = isoparse(entry['begin'])
162 return now - start if start <= now else None 174 return now - start if start <= now else None, entry
175
176 def get_recent_entries(self) -> Generator[Any]:
177 step = timedelta(days = 7)
178 now = datetime.now().astimezone(timezone.utc)
179 ids = set()
180 for req_end in (now - step * i for i in count()):
181 params = {
182 'begin': self.render_datetime(req_end - step),
183 'end': self.render_datetime(req_end),
184 'full': 'true',
185 }
186 for entry in self.get_timesheets(params):
187 if entry['id'] in ids:
188 continue
189 ids.add(entry['id'])
190 yield entry
191
192 def start_clock(self, project_id: int, activity_id: int, description: str | None = None, tags: Iterable[str] | None = None, billable: bool = True):
193 self._session.post('/api/timesheets', json={
194 'begin': self.render_datetime(datetime.now()),
195 'project': project_id,
196 'activity': activity_id,
197 'description': description if description else '',
198 'tags': (','.join(tags)) if tags else '',
199 'billable': billable,
200 }).raise_for_status()
201
202 def stop_clock(self, running_id: int):
203 self._session.patch(f'/api/timesheets/{running_id}/stop').raise_for_status()
204
163 205
164class Worktime(object): 206class Worktime(object):
165 time_worked = timedelta() 207 time_worked = timedelta()
@@ -874,5 +916,116 @@ def main():
874 916
875 args.cmd(**vars(args)) 917 args.cmd(**vars(args))
876 918
919async def ui_update_options(api, cache_path):
920 options = set()
921 sort_order = dict()
922 entry_iter = enumerate(api.get_recent_entries())
923 loop = asyncio.get_event_loop()
924 while item := await loop.run_in_executor(None, next, entry_iter):
925 ix, entry = item
926 if len(options) >= 20 or ix >= 100:
927 break
928
929 option = frozendict({
930 'tags': frozenset(entry['tags']),
931 'activity': frozendict({'id': entry['activity']['id'], 'name': entry['activity']['name']}),
932 'project': frozendict({'id': entry['project']['id'], 'customer': entry['project']['customer']['name'], 'name': entry['project']['name']}),
933 'description': entry['description'] if entry['description'] else None,
934 'billable': entry['billable'],
935 })
936 sort_value = isoparse(entry['begin'])
937 if option in sort_order:
938 sort_value = max(sort_value, sort_order[option])
939 sort_order[option] = sort_value
940 options.add(option)
941
942 options = list(sorted(options, key = lambda o: sort_order[o], reverse = True))
943
944 with cache_path.open('w', encoding='utf-8') as ch:
945 ch.write(jsonpickle.encode(options))
946
947 return options
948
949def ui_render_option(option):
950 res = ''
951 if option['description']:
952 res += '„{}“, '.format(option['description'])
953 res += option['activity']['name'] + ', '
954 res += option['project']['name']
955 if option['project']['customer'] not in option['project']['name']:
956 res += ' ({})'.format(option['project']['customer'])
957 if option['tags']:
958 res += ', {}'.format(' '.join(map(lambda t: '#{}'.format(t), option['tags'])))
959 if not option['billable']:
960 res += ', not billable'
961 return res
962
963async def ui_main():
964 cache_path = Path(BaseDirectory.save_cache_path('worktime-ui')) / 'options.json'
965 options = None
966 try:
967 with cache_path.open('r', encoding='utf-8') as ch:
968 options = jsonpickle.decode(ch.read())
969 except FileNotFoundError:
970 pass
971
972 config = Worktime.config()
973 api = KimaiAPI(
974 base_url=config.get("KIMAI", {}).get("BaseUrl", None),
975 api_token=config.get("KIMAI", {}).get("ApiToken", None),
976 clients=config.get("KIMAI", {}).get("Clients", None)
977 )
978 running_entry = api.get_running_entry()
979
980 async with asyncio.TaskGroup() as tg:
981 update_options = tg.create_task(ui_update_options(api, cache_path))
982 if not options:
983 options = await update_options
984
985 read_fd, write_fd = os.pipe()
986 w_pipe = open(write_fd, 'wb', 0)
987 loop = asyncio.get_event_loop()
988 w_transport, _ = await loop.connect_write_pipe(
989 asyncio.Protocol,
990 w_pipe,
991 )
992 r_pipe = open(read_fd, 'rb', 0)
993
994 proc = await asyncio.create_subprocess_exec(
995 "fuzzel", "--dmenu", "--index", "--width=60",
996 stdout = asyncio.subprocess.PIPE,
997 stdin = r_pipe,
998 )
999
1000 with closing(w_transport) as t:
1001 if running_entry:
1002 t.write(b'Stop running timesheet\n')
1003 for option in options:
1004 t.write(ui_render_option(option).encode('utf-8') + b'\n')
1005
1006 stdout, _ = await proc.communicate()
1007 if proc.returncode != 0:
1008 return
1009 fuzzel_out = int(stdout.decode('utf-8'))
1010 if fuzzel_out < 0 or fuzzel_out >= len(options):
1011 return
1012 elif running_entry and fuzzel_out == 0:
1013 api.stop_clock(running_entry['id'])
1014 else:
1015 if running_entry:
1016 fuzzel_out -= 1
1017 option = options[fuzzel_out]
1018 api.start_clock(
1019 project_id = option['project']['id'],
1020 activity_id = option['activity']['id'],
1021 description = option['description'],
1022 tags = option['tags'],
1023 billable = option['billable'],
1024 )
1025
1026
1027def ui():
1028 asyncio.run(ui_main())
1029
877if __name__ == "__main__": 1030if __name__ == "__main__":
878 sys.exit(main()) 1031 sys.exit(main())