From fb49a24ae1579cf7387d6d44be456776d8edf78b Mon Sep 17 00:00:00 2001 From: 3wc <3wc@doesthisthing.work> Date: Thu, 7 Aug 2025 12:02:35 +0100 Subject: [PATCH] Preliminary clockify support --- hamstertools/__init__.py | 126 +++++++++++++++------- hamstertools/app.py | 44 ++++++-- hamstertools/clockify.py | 65 +++++++++++ hamstertools/db.py | 31 +++++- hamstertools/kimaiapi.py | 1 - hamstertools/screens/clockify/mappings.py | 32 ++++++ hamstertools/screens/clockify/projects.py | 24 +++++ hamstertools/screens/hamster/__init__.py | 2 +- hamstertools/screens/hamster/tags.py | 3 - hamstertools/sync.py | 1 - 10 files changed, 273 insertions(+), 56 deletions(-) create mode 100644 hamstertools/clockify.py create mode 100644 hamstertools/screens/clockify/mappings.py create mode 100644 hamstertools/screens/clockify/projects.py diff --git a/hamstertools/__init__.py b/hamstertools/__init__.py index 9127b26..1a5d2db 100755 --- a/hamstertools/__init__.py +++ b/hamstertools/__init__.py @@ -9,6 +9,7 @@ import sys import tomllib import click +from clockify_sdk import Clockify import requests from peewee import fn, JOIN from textual.logging import TextualHandler @@ -28,6 +29,9 @@ from .db import ( HamsterActivityKimaiMapping, HamsterFactKimaiImport, HamsterFactTag, + ClockifyProject, + HamsterClockifyMapping, + HamsterFactClockifyImport ) from .kimaiapi import KimaiAPI, Timesheet from .sync import sync @@ -43,11 +47,8 @@ db.init(HAMSTER_FILE) @click.group(context_settings={"auto_envvar_prefix": "HAMSTERTOOL"}) @click.option("-d", "--debug", is_flag=True) @click.option("--config", default=CONFIG_FILE, type=click.Path()) -@click.option("--kimai-api-url", envvar="KIMAI_API_URL") -@click.option("--kimai-username", envvar="KIMAI_USERNAME") -@click.option("--kimai-api-key", envvar="KIMAI_API_KEY") @click.pass_context -def cli(ctx, config, debug, kimai_api_url=None, kimai_username=None, kimai_api_key=None): +def cli(ctx, config, debug): file_config = {} if os.path.exists(config): with open(config, "rb") as f: @@ -64,11 +65,8 @@ def cli(ctx, config, debug, kimai_api_url=None, kimai_username=None, kimai_api_k requests_log.setLevel(logging.DEBUG) requests_log.propagate = True - ctx.obj = KimaiAPI( - kimai_username if kimai_username is not None else file_config.get("kimai_username"), - kimai_api_key if kimai_api_key is not None else file_config.get("kimai_api_key"), - kimai_api_url if kimai_api_url is not None else file_config.get("kimai_api_url"), - ) + ctx.ensure_object(dict) + ctx.obj['config'] = file_config @cli.group() @@ -334,7 +332,17 @@ def find_duplicates(): @cli.group() -def kimai(): +@click.option("--kimai-api-url", envvar="KIMAI_API_URL") +@click.option("--kimai-username", envvar="KIMAI_USERNAME") +@click.option("--kimai-api-key", envvar="KIMAI_API_KEY") +@click.pass_context +def kimai(ctx, kimai_api_url, kimai_username, kimai_api_key): + file_config = ctx.obj['config'] + ctx.obj['kimai'] = KimaiAPI( + kimai_username if kimai_username is not None else file_config.get("kimai_username"), + kimai_api_key if kimai_api_key is not None else file_config.get("kimai_api_key"), + kimai_api_url if kimai_api_url is not None else file_config.get("kimai_api_url"), + ) pass @@ -535,7 +543,7 @@ def _csv( timestamp = datetime.now().strftime("%F") output = f"kimai_{timestamp}.csv" - if type(mapping_path) == tuple: + if isinstance(mapping_path, tuple): mapping_files = [] for mapping_path_item in mapping_path: mapping_file = _get_kimai_mapping_file(mapping_path_item, category_search) @@ -552,7 +560,7 @@ def _csv( for row in mapping_reader } - if type(mapping_path) == tuple: + if isinstance(mapping_path, tuple): for mapping_file in mapping_files: mapping_file.close() else: @@ -561,25 +569,13 @@ def _csv( output_file = open(output, "w") output_writer = csv.writer(output_file) - args = [] - facts = ( HamsterFact.select(HamsterFact, HamsterActivity, HamsterCategory) .join(HamsterActivity) .join(HamsterCategory, JOIN.LEFT_OUTER) ) - sql = """ - SELECT - facts.id, facts.start_time, facts.end_time, facts.description, - activities.id, activities.name, - categories.name, categories.id - FROM - facts - LEFT JOIN - activities ON facts.activity_id = activities.id - LEFT JOIN - categories ON activities.category_id = categories.id """ + if category_search is not None: facts = facts.where(HamsterCategory.name.contains(category_search)) @@ -665,8 +661,9 @@ def _csv( @click.argument("search") @click.argument("after") @click.argument("before") -def _import(search, after, before): - api = KimaiAPI(username=KIMAI_USERNAME, api_key=KIMAI_API_KEY) +@click.pass_context +def _import(ctx, search, after, before): + api = ctx.obj SEARCH = "auto" @@ -760,11 +757,11 @@ def _import(search, after, before): @kimai.group("db") -def db_(): +def kimai_db(): pass -@db_.command() +@kimai_db.command() def init(): db.create_tables( [ @@ -778,20 +775,20 @@ def init(): ) -@db_.command() +@kimai_db.command() def reset(): HamsterActivityKimaiMapping.delete().execute() -@db_.command("sync") -@click.pass_obj -def kimai_db_sync(api): +@kimai_db.command("sync") +@click.pass_context +def kimai_db_sync(ctx): sync( - api + ctx.obj['kimai'] ) -@db_.command() +@kimai_db.command() @click.option( "-g", "--global", @@ -828,14 +825,63 @@ def mapping2db(mapping_path=None, global_=False): ) +@cli.group() +@click.option("--api-key", envvar="CLOCKIFY_API_KEY", help="Clockify API key") +@click.option("--workspace-id", envvar="CLOCKIFY_WORKSPACE_ID", help="Clockify workspace ID") +@click.pass_context +def clockify(ctx, api_key, workspace_id): + file_config = ctx.obj['config'] + ctx.obj['clockify'] = Clockify( + api_key=api_key if api_key is not None else file_config.get("clockify_api_key"), + workspace_id=workspace_id if workspace_id is not None else file_config.get("clockify_workspace_id") + ) + + +@clockify.group("db") +def clockify_db(): + pass + + +@clockify_db.command("sync") +@click.pass_context +def clockify_db_sync(ctx): + from .clockify import sync_projects + count = sync_projects(ctx.obj['clockify'], db) + click.echo(f"Synced {count} Clockify projects") + + +@clockify_db.command("init") +def clockify_db_init(): + db.create_tables( + [ + ClockifyProject, + HamsterClockifyMapping, + HamsterFactClockifyImport, + ] + ) + +@clockify.command("app") +@click.pass_context +def clockify_app(ctx): + from .app import HamsterToolsAppClockify + + app = HamsterToolsAppClockify(ctx.obj['clockify']) + app.run() + +@kimai.command("app") +@click.pass_context +def kimai_app(ctx): + from .app import HamsterToolsAppKimai + + app = HamsterToolsAppKimai(ctx.obj['kimai']) + app.run() + + @cli.command() -@click.pass_obj -def app(kimai_api): +def app(): from .app import HamsterToolsApp - app = HamsterToolsApp( - kimai_api - ) + app = HamsterToolsApp() app.run() diff --git a/hamstertools/app.py b/hamstertools/app.py index 2f4ff15..584db69 100644 --- a/hamstertools/app.py +++ b/hamstertools/app.py @@ -1,10 +1,12 @@ from textual.app import App +from hamstertools import clockify + from .db import db -from .kimaiapi import KimaiAPI from .screens.hamster import HamsterScreen from .screens.kimai import KimaiScreen +from .screens.clockify.projects import ClockifyProjectScreen class HamsterToolsApp(App): @@ -12,18 +14,19 @@ class HamsterToolsApp(App): BINDINGS = [ ("h", "switch_mode('hamster')", "Hamster"), - ("k", "switch_mode('kimai')", "Kimai"), ("q", "quit", "Quit"), ] - def __init__(self, kimai_api=None): + def __init__(self, kimai_api=None, clockify_api=None): self.add_mode("hamster", HamsterScreen()) - self.add_mode("kimai", KimaiScreen()) - # self.mode MODES = { - # "hamster": HamsterScreen(), - # "kimai": KimaiScreen(), - # } - self.api = kimai_api + + if kimai_api is not None: + self.kimai_api = kimai_api + self.add_mode("kimai", KimaiScreen()) + + if clockify_api is not None: + self.clockify_api = clockify_api + self.add_mode("clockify", ClockifyProjectScreen()) super().__init__() @@ -33,3 +36,26 @@ class HamsterToolsApp(App): async def action_quit(self) -> None: db.close() self.exit() + + +class HamsterToolsAppKimai(HamsterToolsApp): + BINDINGS = HamsterToolsApp.BINDINGS + [ + ("k", "switch_mode('kimai')", "Kimai"), + ] + + def __init__(self, kimai_api): + self.kimai_api = kimai_api + self.add_mode("kimai", KimaiScreen()) + super().__init__() + + +class HamsterToolsAppClockify(HamsterToolsApp): + BINDINGS = HamsterToolsApp.BINDINGS + [ + ("c", "switch_mode('clockify_projects')", "Clockify"), + ] + + + def __init__(self, clockify_api): + self.clockify_api = clockify_api + self.add_mode("clockify_projects", ClockifyProjectScreen()) + super().__init__() diff --git a/hamstertools/clockify.py b/hamstertools/clockify.py new file mode 100644 index 0000000..7836620 --- /dev/null +++ b/hamstertools/clockify.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass, field +from datetime import datetime +from clockify_sdk import Clockify + +class NotFound(Exception): + pass + + +@dataclass() +class Project: + api: Clockify = field(repr=False) + id: str + name: str + workspace_id: str + + @staticmethod + def list(api): + projects = api.projects.get_all() + return [ + Project(api, p['id'], p['name'], p['workspaceId']) + for p in projects + ] + + @staticmethod + def get_by_id(api, project_id): + return api.projects.get_by_id(project_id) + + +def sync_projects(api, db): + """Sync Clockify projects to local database""" + from .db import ClockifyProject + + # Fetch and store all projects + projects = Project.list(api) + with db.atomic(): + # Delete all existing projects + ClockifyProject.delete().execute() + + query = ClockifyProject.insert_many( + [ + { + "clockify_id": project.id, + "name": project.name, + "workspace_id": project.workspace_id + } + for project in projects + ] + ) + breakpoint() + query.execute() + return len(projects) + + +def export_fact(api, fact, project_id): + """Export a Hamster fact to Clockify as a time entry""" + start = fact.start_time.isoformat() + end = fact.end_time.isoformat() if fact.end_time else datetime.now().isoformat() + + time_entry = api.create_time_entry( + project_id=project_id, + start=start, + end=end, + description=fact.description + ) + return time_entry.id diff --git a/hamstertools/db.py b/hamstertools/db.py index c915366..4b3f3e3 100644 --- a/hamstertools/db.py +++ b/hamstertools/db.py @@ -1,7 +1,6 @@ from datetime import datetime from peewee import ( - CompositeKey, SqliteDatabase, Model, CharField, @@ -122,3 +121,33 @@ class HamsterFactKimaiImport(Model): class Meta: database = db table_name = "hamster_fact_kimai_imports" + + +class ClockifyProject(Model): + clockify_id = CharField() + name = CharField() + workspace_id = CharField() + + class Meta: + database = db + table_name = "clockify_projects" + + +class HamsterClockifyMapping(Model): + hamster_activity = ForeignKeyField(HamsterActivity, backref="clockify_mappings") + clockify_project = ForeignKeyField(ClockifyProject, backref="mappings") + created_at = DateTimeField(default=datetime.now) + + class Meta: + database = db + table_name = "hamster_clockify_mappings" + + +class HamsterFactClockifyImport(Model): + hamster_fact = ForeignKeyField(HamsterFact, backref="clockify_imports") + clockify_time_entry_id = CharField() + exported_at = DateTimeField(default=datetime.now) + + class Meta: + database = db + table_name = "hamster_fact_clockify_imports" diff --git a/hamstertools/kimaiapi.py b/hamstertools/kimaiapi.py index 1bd5a14..c61520b 100644 --- a/hamstertools/kimaiapi.py +++ b/hamstertools/kimaiapi.py @@ -1,5 +1,4 @@ from datetime import datetime -import pdb import requests from dataclasses import dataclass, field diff --git a/hamstertools/screens/clockify/mappings.py b/hamstertools/screens/clockify/mappings.py new file mode 100644 index 0000000..08a9fdc --- /dev/null +++ b/hamstertools/screens/clockify/mappings.py @@ -0,0 +1,32 @@ +from textual.app import ComposeResult +from textual.containers import VerticalScroll +from textual.screen import Screen +from textual.widgets import DataTable, Header, Footer + +from hamstertools.db import HamsterActivity, ClockifyProject, HamsterClockifyMapping + + +class ClockifyMappingScreen(Screen): + """Screen for managing Clockify activity-project mappings""" + + BINDINGS = [("q", "quit", "Quit")] + + def compose(self) -> ComposeResult: + yield Header() + yield VerticalScroll(DataTable()) + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("Hamster Activity", "Clockify Project") + + # Query all mappings + mappings = (HamsterClockifyMapping + .select() + .join(HamsterActivity) + .join(ClockifyProject)) + + for mapping in mappings: + activity = f"{mapping.hamster_activity.category.name} ยป {mapping.hamster_activity.name}" + project = mapping.clockify_project.name + table.add_row(activity, project) \ No newline at end of file diff --git a/hamstertools/screens/clockify/projects.py b/hamstertools/screens/clockify/projects.py new file mode 100644 index 0000000..0002695 --- /dev/null +++ b/hamstertools/screens/clockify/projects.py @@ -0,0 +1,24 @@ +from textual.app import ComposeResult +from textual.containers import VerticalScroll +from textual.screen import Screen +from textual.widgets import DataTable, Header, Footer + +from hamstertools.db import ClockifyProject + + +class ClockifyProjectScreen(Screen): + """Screen for listing Clockify projects""" + + BINDINGS = [("q", "quit", "Quit")] + + def compose(self) -> ComposeResult: + yield Header() + yield VerticalScroll(DataTable()) + yield Footer() + + def on_mount(self) -> None: + table = self.query_one(DataTable) + table.add_columns("ID", "Name", "Workspace ID") + + for project in ClockifyProject.select(): + table.add_row(project.clockify_id, project.name, project.workspace_id) \ No newline at end of file diff --git a/hamstertools/screens/hamster/__init__.py b/hamstertools/screens/hamster/__init__.py index e57f4dd..95b417b 100644 --- a/hamstertools/screens/hamster/__init__.py +++ b/hamstertools/screens/hamster/__init__.py @@ -14,7 +14,7 @@ from .tags import TagList class HamsterScreen(Screen): BINDINGS = [ - ("c", "show_tab('categories')", "Categories"), + ("g", "show_tab('categories')", "Categories"), ("a", "show_tab('activities')", "Activities"), ("t", "show_tab('tags')", "Tags"), ] diff --git a/hamstertools/screens/hamster/tags.py b/hamstertools/screens/hamster/tags.py index b41eee4..7d23336 100644 --- a/hamstertools/screens/hamster/tags.py +++ b/hamstertools/screens/hamster/tags.py @@ -1,14 +1,11 @@ from peewee import JOIN, fn -from textual import on from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.coordinate import Coordinate -from textual.events import DescendantBlur from textual.screen import ModalScreen from textual.widgets import DataTable, Input, Label -from textual_autocomplete import AutoComplete, Dropdown from hamstertools.db import HamsterFactTag, HamsterTag from hamstertools.screens.list import ListPane diff --git a/hamstertools/sync.py b/hamstertools/sync.py index e8f5a02..fa4987e 100644 --- a/hamstertools/sync.py +++ b/hamstertools/sync.py @@ -1,5 +1,4 @@ from .kimaiapi import ( - KimaiAPI, Customer as KimaiAPICustomer, Project as KimaiAPIProject, Activity as KimaiAPIActivity,