From bc8e5d8c78c6997eca3258b7c4c20c727e7f0063 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Sun, 25 May 2025 14:26:32 +0200 Subject: worktime-ui --- accounts/gkleen@sif/default.nix | 2 +- accounts/gkleen@sif/niri/default.nix | 30 ++++--- overlays/worktime/pyproject.toml | 2 + overlays/worktime/uv.lock | 13 +++ overlays/worktime/worktime/__main__.py | 159 ++++++++++++++++++++++++++++++++- 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 { font = "Fira Sans"; }; colors = { - background = "000000aa"; + background = "000000cc"; text = "cdd6f4ff"; match = "94e2d5ff"; 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 windows_json="$(niri msg -j windows)" active_workspace="$(jq -r '.[] | select(.is_focused) | .workspace_id' <<<"$windows_json")" - 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)" + 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)" # shellcheck disable=SC2016 window_json="$(gojq -rc --arg active_workspace "$active_workspace" --arg window_ix "$window_ix" 'map(select('"$window_select"')) | .[($window_ix | tonumber)]' <<<"$windows_json")" @@ -449,7 +449,7 @@ in { { title = "^Access Request.*"; } { title = ".*Passkey credentials$"; } ]; - windowRuleExtra = [ + windowRuleExtra = with kdl; [ (kdl.leaf "open-focused" false) ]; key = "Mod+Control+P"; @@ -477,6 +477,20 @@ in { app-id = "com.github.wwmm.easyeffects"; spawn = [ "easyeffects" ]; } + { name = "time"; + key = "Mod+Control+K"; + app-id = "chrome-kimai.yggdrasil.li__-Default"; + spawn = [ (toString (pkgs.resholve.writeScript "kimai" { + interpreter = pkgs.runtimeShell; + inputs = [ pkgs.dex ]; + execer = [ "cannot:${lib.getExe pkgs.dex}" ]; + } '' + exec dex $HOME/.local/state/nix/profile/share/applications/kimai.desktop + '')) ]; + windowRuleExtra = with kdl; [ + (leaf "block-out-from" "screencast") + ]; + } ]; programs.niri.config = let @@ -682,12 +696,6 @@ in { (leaf "match" { app-id = "^chrome-web\.openrainbow\.com__-Default$"; }) (leaf "open-on-workspace" "comm") ]) - (plain "window-rule" [ - (leaf "match" { app-id = "^chrome-kimai\.yggdrasil\.li__-Default$"; }) - (leaf "open-on-workspace" "comm") - (leaf "open-fullscreen" false) - (plain "default-column-width" [(leaf "proportion" (2. / 3.))]) - ]) (plain "window-rule" [ (leaf "match" { app-id = "^firefox$"; }) (leaf "open-on-workspace" "web") @@ -803,7 +811,7 @@ in { 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) $FOUND || echo } - FUZZEL_RES=$(prev | fuzzel --dmenu --prompt "qalc> ") || exit $? + FUZZEL_RES=$(prev | fuzzel --dmenu --prompt "qalc> " --width=60) || exit $? if [[ "$FUZZEL_RES" =~ .*\ =\ .* ]]; then QALC_RES="$FUZZEL_RES" QALC_RET=0 @@ -832,7 +840,7 @@ in { name = "emoji-fuzzel"; runtimeInputs = with pkgs; [ config.programs.fuzzel.package wtype wl-clipboard-rs ]; text = '' - FUZZEL_RES=$(fuzzel --dmenu --prompt "emoji> " <"$HOME"/.local/share/emoji-data/list.txt) || exit $? + FUZZEL_RES=$(fuzzel --dmenu --prompt "emoji> " --cache "$HOME"/.cache/fuzzel-emoji --width=60 <"$HOME"/.local/share/emoji-data/list.txt) || exit $? [[ -n "$FUZZEL_RES" ]] || exit 1 wl-copy "$(cut -d ':' -f 1 <<<"$FUZZEL_RES" | tr -d '\n')" && wtype -k XF86Paste ''; @@ -973,6 +981,8 @@ in { "Mod+D".action = with-urgent-window-action "{\"Action\":{\"FocusWindow\":{\"id\": .id}}}"; "Mod+Shift+D".action = with-focused-window-action "{\"Action\":{\"UnsetUrgent\":{\"id\": .id}}}"; + + "Mod+K".action = spawn (lib.getExe' pkgs.worktime "worktime-ui"); })) (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) (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 = [ "tabulate>=0.9.0,<0.10", "toml>=0.10.2,<0.11", "jsonpickle>=4.0.5,<5", + "frozendict>=2.4.6", ] [project.scripts] worktime = "worktime.__main__:main" +worktime-ui = "worktime.__main__:ui" [build-system] requires = ["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 @@ -46,6 +46,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, ] +[[package]] +name = "frozendict" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { 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" } +wheels = [ + { 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" }, + { 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" }, + { 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" }, +] + [[package]] name = "idna" version = "3.10" @@ -150,6 +161,7 @@ name = "worktime" version = "1.0.0" source = { editable = "." } dependencies = [ + { name = "frozendict" }, { name = "jsonpickle" }, { name = "python-dateutil" }, { name = "pyxdg" }, @@ -161,6 +173,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "frozendict", specifier = ">=2.4.6" }, { name = "jsonpickle", specifier = ">=4.0.5,<5" }, { name = "python-dateutil", specifier = ">=2.9.0.post0,<3" }, { 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 from tabulate import tabulate -from itertools import groupby, count +from itertools import groupby, count, islice from functools import cache, partial from pathlib import Path @@ -42,6 +42,12 @@ import jsonpickle from hashlib import blake2s import json +import asyncio + +from frozendict import frozendict +from contextlib import closing +import os + class BearerAuth(requests.auth.AuthBase): def __init__(self, token): self.token = token @@ -149,7 +155,7 @@ class KimaiAPI(object): def get_billable_hours(self, start_date: datetime, end_date: datetime = datetime.now(timezone.utc)) -> timedelta: return sum(self.entry_durations(start_date, end_date=end_date), start=timedelta(milliseconds=0)) - def get_running_clock(self, now: datetime = datetime.now(timezone.utc)) -> timedelta | None: + def get_running_entry(self) -> Any | None: kimai_entries = self._session.get('/api/timesheets/active').json() if not kimai_entries: return None @@ -158,8 +164,44 @@ class KimaiAPI(object): if entry['project']['customer']['id'] not in self._client_ids: return None + return entry + + def get_running_clock(self, now: datetime = datetime.now(timezone.utc)) -> timedelta | None: + entry = self.get_running_entry() + if not entry: + return None start = isoparse(entry['begin']) - return now - start if start <= now else None + return now - start if start <= now else None, entry + + def get_recent_entries(self) -> Generator[Any]: + step = timedelta(days = 7) + now = datetime.now().astimezone(timezone.utc) + ids = set() + for req_end in (now - step * i for i in count()): + params = { + 'begin': self.render_datetime(req_end - step), + 'end': self.render_datetime(req_end), + 'full': 'true', + } + for entry in self.get_timesheets(params): + if entry['id'] in ids: + continue + ids.add(entry['id']) + yield entry + + def start_clock(self, project_id: int, activity_id: int, description: str | None = None, tags: Iterable[str] | None = None, billable: bool = True): + self._session.post('/api/timesheets', json={ + 'begin': self.render_datetime(datetime.now()), + 'project': project_id, + 'activity': activity_id, + 'description': description if description else '', + 'tags': (','.join(tags)) if tags else '', + 'billable': billable, + }).raise_for_status() + + def stop_clock(self, running_id: int): + self._session.patch(f'/api/timesheets/{running_id}/stop').raise_for_status() + class Worktime(object): time_worked = timedelta() @@ -874,5 +916,116 @@ def main(): args.cmd(**vars(args)) +async def ui_update_options(api, cache_path): + options = set() + sort_order = dict() + entry_iter = enumerate(api.get_recent_entries()) + loop = asyncio.get_event_loop() + while item := await loop.run_in_executor(None, next, entry_iter): + ix, entry = item + if len(options) >= 20 or ix >= 100: + break + + option = frozendict({ + 'tags': frozenset(entry['tags']), + 'activity': frozendict({'id': entry['activity']['id'], 'name': entry['activity']['name']}), + 'project': frozendict({'id': entry['project']['id'], 'customer': entry['project']['customer']['name'], 'name': entry['project']['name']}), + 'description': entry['description'] if entry['description'] else None, + 'billable': entry['billable'], + }) + sort_value = isoparse(entry['begin']) + if option in sort_order: + sort_value = max(sort_value, sort_order[option]) + sort_order[option] = sort_value + options.add(option) + + options = list(sorted(options, key = lambda o: sort_order[o], reverse = True)) + + with cache_path.open('w', encoding='utf-8') as ch: + ch.write(jsonpickle.encode(options)) + + return options + +def ui_render_option(option): + res = '' + if option['description']: + res += '„{}“, '.format(option['description']) + res += option['activity']['name'] + ', ' + res += option['project']['name'] + if option['project']['customer'] not in option['project']['name']: + res += ' ({})'.format(option['project']['customer']) + if option['tags']: + res += ', {}'.format(' '.join(map(lambda t: '#{}'.format(t), option['tags']))) + if not option['billable']: + res += ', not billable' + return res + +async def ui_main(): + cache_path = Path(BaseDirectory.save_cache_path('worktime-ui')) / 'options.json' + options = None + try: + with cache_path.open('r', encoding='utf-8') as ch: + options = jsonpickle.decode(ch.read()) + except FileNotFoundError: + pass + + config = Worktime.config() + api = KimaiAPI( + base_url=config.get("KIMAI", {}).get("BaseUrl", None), + api_token=config.get("KIMAI", {}).get("ApiToken", None), + clients=config.get("KIMAI", {}).get("Clients", None) + ) + running_entry = api.get_running_entry() + + async with asyncio.TaskGroup() as tg: + update_options = tg.create_task(ui_update_options(api, cache_path)) + if not options: + options = await update_options + + read_fd, write_fd = os.pipe() + w_pipe = open(write_fd, 'wb', 0) + loop = asyncio.get_event_loop() + w_transport, _ = await loop.connect_write_pipe( + asyncio.Protocol, + w_pipe, + ) + r_pipe = open(read_fd, 'rb', 0) + + proc = await asyncio.create_subprocess_exec( + "fuzzel", "--dmenu", "--index", "--width=60", + stdout = asyncio.subprocess.PIPE, + stdin = r_pipe, + ) + + with closing(w_transport) as t: + if running_entry: + t.write(b'Stop running timesheet\n') + for option in options: + t.write(ui_render_option(option).encode('utf-8') + b'\n') + + stdout, _ = await proc.communicate() + if proc.returncode != 0: + return + fuzzel_out = int(stdout.decode('utf-8')) + if fuzzel_out < 0 or fuzzel_out >= len(options): + return + elif running_entry and fuzzel_out == 0: + api.stop_clock(running_entry['id']) + else: + if running_entry: + fuzzel_out -= 1 + option = options[fuzzel_out] + api.start_clock( + project_id = option['project']['id'], + activity_id = option['activity']['id'], + description = option['description'], + tags = option['tags'], + billable = option['billable'], + ) + + +def ui(): + asyncio.run(ui_main()) + if __name__ == "__main__": sys.exit(main()) -- cgit v1.2.3