Preliminary clockify support
This commit is contained in:
@ -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()
|
||||
|
||||
|
||||
|
@ -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())
|
||||
|
||||
if kimai_api is not None:
|
||||
self.kimai_api = kimai_api
|
||||
self.add_mode("kimai", KimaiScreen())
|
||||
# self.mode MODES = {
|
||||
# "hamster": HamsterScreen(),
|
||||
# "kimai": KimaiScreen(),
|
||||
# }
|
||||
self.api = kimai_api
|
||||
|
||||
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
65
hamstertools/clockify.py
Normal 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
|
@ -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"
|
||||
|
@ -1,5 +1,4 @@
|
||||
from datetime import datetime
|
||||
import pdb
|
||||
import requests
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
32
hamstertools/screens/clockify/mappings.py
Normal file
32
hamstertools/screens/clockify/mappings.py
Normal 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)
|
24
hamstertools/screens/clockify/projects.py
Normal file
24
hamstertools/screens/clockify/projects.py
Normal 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)
|
@ -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"),
|
||||
]
|
||||
|
@ -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
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
from .kimaiapi import (
|
||||
KimaiAPI,
|
||||
Customer as KimaiAPICustomer,
|
||||
Project as KimaiAPIProject,
|
||||
Activity as KimaiAPIActivity,
|
||||
|
Reference in New Issue
Block a user