summaryrefslogtreecommitdiff
path: root/overlays
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2025-05-25 14:26:32 +0200
committerGregor Kleen <gkleen@yggdrasil.li>2025-05-25 14:26:32 +0200
commitbc8e5d8c78c6997eca3258b7c4c20c727e7f0063 (patch)
tree8523d34867f341c7bb02994baa0710aa54e54e42 /overlays
parent15b3cfbf4a4ed2c85cfd8eea3e67da87c0c37db6 (diff)
downloadnixos-bc8e5d8c78c6997eca3258b7c4c20c727e7f0063.tar
nixos-bc8e5d8c78c6997eca3258b7c4c20c727e7f0063.tar.gz
nixos-bc8e5d8c78c6997eca3258b7c4c20c727e7f0063.tar.bz2
nixos-bc8e5d8c78c6997eca3258b7c4c20c727e7f0063.tar.xz
nixos-bc8e5d8c78c6997eca3258b7c4c20c727e7f0063.zip
worktime-ui
Diffstat (limited to 'overlays')
-rw-r--r--overlays/worktime/pyproject.toml2
-rw-r--r--overlays/worktime/uv.lock13
-rwxr-xr-xoverlays/worktime/worktime/__main__.py159
3 files changed, 171 insertions, 3 deletions
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())