Preliminary clockify support

This commit is contained in:
3wc
2025-08-07 12:02:35 +01:00
parent db6d7ac640
commit fb49a24ae1
10 changed files with 273 additions and 56 deletions

View File

@ -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()

View File

@ -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__()

65
hamstertools/clockify.py Normal file
View File

@ -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

View File

@ -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"

View File

@ -1,5 +1,4 @@
from datetime import datetime
import pdb
import requests
from dataclasses import dataclass, field

View File

@ -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)

View File

@ -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)

View File

@ -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"),
]

View File

@ -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

View File

@ -1,5 +1,4 @@
from .kimaiapi import (
KimaiAPI,
Customer as KimaiAPICustomer,
Project as KimaiAPIProject,
Activity as KimaiAPIActivity,