sigye 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sigye-0.1.0/.gitignore +13 -0
- sigye-0.1.0/PKG-INFO +104 -0
- sigye-0.1.0/README.md +91 -0
- sigye-0.1.0/pyproject.toml +28 -0
- sigye-0.1.0/src/sigye/__init__.py +0 -0
- sigye-0.1.0/src/sigye/cli.py +171 -0
- sigye-0.1.0/src/sigye/models.py +87 -0
- sigye-0.1.0/src/sigye/output/__init__.py +0 -0
- sigye-0.1.0/src/sigye/output/text_output.py +60 -0
- sigye-0.1.0/src/sigye/repositories/__init__.py +0 -0
- sigye-0.1.0/src/sigye/repositories/time_entry_repo.py +28 -0
- sigye-0.1.0/src/sigye/repositories/time_entry_repo_yaml.py +99 -0
- sigye-0.1.0/src/sigye/services.py +64 -0
- sigye-0.1.0/src/sigye/tests/__init__.py +0 -0
- sigye-0.1.0/src/sigye/tests/test_models.py +96 -0
- sigye-0.1.0/src/sigye/tests/test_services.py +101 -0
- sigye-0.1.0/time_entries.yaml +40 -0
- sigye-0.1.0/uv.lock +333 -0
sigye-0.1.0/.gitignore
ADDED
sigye-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sigye
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Add your description here
|
|
5
|
+
Author-email: swilcox <steven@wilcoxzone.com>
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: click>=8.1.7
|
|
8
|
+
Requires-Dist: humanize>=4.11.0
|
|
9
|
+
Requires-Dist: pydantic>=2.9.2
|
|
10
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
11
|
+
Requires-Dist: rich>=13.9.4
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# sigye (시계)
|
|
15
|
+
|
|
16
|
+
A simple, command-line time tracking program.
|
|
17
|
+
|
|
18
|
+
## Overview
|
|
19
|
+
|
|
20
|
+
sigye (시계 Korean for hour(s)/time) is a CLI program to help you track your time. With sigye, there are basic operations:
|
|
21
|
+
* start (start tracking time towards a project)
|
|
22
|
+
* stop (stop tracking time)
|
|
23
|
+
* status (get the current status)
|
|
24
|
+
* edit (edit a time entry record using the current default EDITOR)
|
|
25
|
+
* list (list entries)
|
|
26
|
+
* can filter entries by time range ("today", "week", "month") or fixed start and end dates.
|
|
27
|
+
* can filter entries by project name(s) or a project starts with.
|
|
28
|
+
* can filter entries by tags name(s).
|
|
29
|
+
|
|
30
|
+
The default storage of time entries is a YAML file (near future will be sqlite support). Using YAML makes manual editing of the entire file possible using any editor.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Via `uv`
|
|
35
|
+
```shell
|
|
36
|
+
uv tool install sigye
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Via `pipx`
|
|
40
|
+
```shell
|
|
41
|
+
pipx install sigye
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Start tracking
|
|
47
|
+
```shell
|
|
48
|
+
sigye start <project-name> "<optional comment>" --tag "optional_tag"
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Check status
|
|
52
|
+
```shell
|
|
53
|
+
sigye status
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Stop tracking
|
|
57
|
+
```shell
|
|
58
|
+
sigye stop
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### List Entries
|
|
62
|
+
#### List All Entries
|
|
63
|
+
```shell
|
|
64
|
+
sigye list
|
|
65
|
+
```
|
|
66
|
+
#### List Filtered Entries
|
|
67
|
+
|
|
68
|
+
All entries from a named time frame (options: `today`, `week` and `month`):
|
|
69
|
+
```shell
|
|
70
|
+
sigye list TIMEFRAME
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
All entries for a certain project (or list of projects)
|
|
74
|
+
```shell
|
|
75
|
+
sigye list --project abc-1234 --project abc-1233
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Entries that "start with" a project name (note: you can use `+` or `.` or `*`):
|
|
79
|
+
```
|
|
80
|
+
sigye list --project abc+
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
All entries with any tag matching a tag or multiple tags:
|
|
84
|
+
```
|
|
85
|
+
sigye list --tag mytag
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Edit Entries
|
|
89
|
+
To edit an entry, use the full or partial ID (just has to be enough digits for it to be unique among your time entry file or data). By default, sigye shows the first 4 digits from an entry ID.
|
|
90
|
+
```shell
|
|
91
|
+
sigye edit ID
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Development
|
|
95
|
+
|
|
96
|
+
### Install requirements
|
|
97
|
+
|
|
98
|
+
This project uses `uv` for dependency management.
|
|
99
|
+
|
|
100
|
+
### Running tests
|
|
101
|
+
|
|
102
|
+
```shell
|
|
103
|
+
uv run pytest
|
|
104
|
+
```
|
sigye-0.1.0/README.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# sigye (시계)
|
|
2
|
+
|
|
3
|
+
A simple, command-line time tracking program.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
sigye (시계 Korean for hour(s)/time) is a CLI program to help you track your time. With sigye, there are basic operations:
|
|
8
|
+
* start (start tracking time towards a project)
|
|
9
|
+
* stop (stop tracking time)
|
|
10
|
+
* status (get the current status)
|
|
11
|
+
* edit (edit a time entry record using the current default EDITOR)
|
|
12
|
+
* list (list entries)
|
|
13
|
+
* can filter entries by time range ("today", "week", "month") or fixed start and end dates.
|
|
14
|
+
* can filter entries by project name(s) or a project starts with.
|
|
15
|
+
* can filter entries by tags name(s).
|
|
16
|
+
|
|
17
|
+
The default storage of time entries is a YAML file (near future will be sqlite support). Using YAML makes manual editing of the entire file possible using any editor.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
### Via `uv`
|
|
22
|
+
```shell
|
|
23
|
+
uv tool install sigye
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Via `pipx`
|
|
27
|
+
```shell
|
|
28
|
+
pipx install sigye
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
### Start tracking
|
|
34
|
+
```shell
|
|
35
|
+
sigye start <project-name> "<optional comment>" --tag "optional_tag"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Check status
|
|
39
|
+
```shell
|
|
40
|
+
sigye status
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Stop tracking
|
|
44
|
+
```shell
|
|
45
|
+
sigye stop
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### List Entries
|
|
49
|
+
#### List All Entries
|
|
50
|
+
```shell
|
|
51
|
+
sigye list
|
|
52
|
+
```
|
|
53
|
+
#### List Filtered Entries
|
|
54
|
+
|
|
55
|
+
All entries from a named time frame (options: `today`, `week` and `month`):
|
|
56
|
+
```shell
|
|
57
|
+
sigye list TIMEFRAME
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
All entries for a certain project (or list of projects)
|
|
61
|
+
```shell
|
|
62
|
+
sigye list --project abc-1234 --project abc-1233
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Entries that "start with" a project name (note: you can use `+` or `.` or `*`):
|
|
66
|
+
```
|
|
67
|
+
sigye list --project abc+
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
All entries with any tag matching a tag or multiple tags:
|
|
71
|
+
```
|
|
72
|
+
sigye list --tag mytag
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Edit Entries
|
|
76
|
+
To edit an entry, use the full or partial ID (just has to be enough digits for it to be unique among your time entry file or data). By default, sigye shows the first 4 digits from an entry ID.
|
|
77
|
+
```shell
|
|
78
|
+
sigye edit ID
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
### Install requirements
|
|
84
|
+
|
|
85
|
+
This project uses `uv` for dependency management.
|
|
86
|
+
|
|
87
|
+
### Running tests
|
|
88
|
+
|
|
89
|
+
```shell
|
|
90
|
+
uv run pytest
|
|
91
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sigye"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "swilcox", email = "steven@wilcoxzone.com" }]
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"click>=8.1.7",
|
|
10
|
+
"humanize>=4.11.0",
|
|
11
|
+
"pydantic>=2.9.2",
|
|
12
|
+
"pyyaml>=6.0.2",
|
|
13
|
+
"rich>=13.9.4",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
sigye = "sigye.cli:cli"
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["hatchling"]
|
|
21
|
+
build-backend = "hatchling.build"
|
|
22
|
+
|
|
23
|
+
[dependency-groups]
|
|
24
|
+
dev = [
|
|
25
|
+
"freezegun>=1.5.1",
|
|
26
|
+
"pytest-cov>=6.0.0",
|
|
27
|
+
"pytest>=8.3.3",
|
|
28
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from datetime import datetime, timedelta
|
|
2
|
+
import functools
|
|
3
|
+
import tempfile
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from .models import EntryListFilter, TimeEntry
|
|
11
|
+
from .output.text_output import list_output, single_entry_output
|
|
12
|
+
from .services import TimeTrackingService
|
|
13
|
+
from .repositories.time_entry_repo import TimeEntryRepository
|
|
14
|
+
from .repositories.time_entry_repo_yaml import TimeEntryRepositoryYaml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_repo_from_filename(filename: str) -> TimeEntryRepository:
|
|
18
|
+
# TODO: add logic here to swap to sqlite or something else based on the filename
|
|
19
|
+
return TimeEntryRepositoryYaml(filename)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def config_params(func):
|
|
23
|
+
@click.option("--filename", "-f", default="time_entries.yaml")
|
|
24
|
+
@functools.wraps(func)
|
|
25
|
+
def wrapper(*args, **kwargs):
|
|
26
|
+
return func(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
return wrapper
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _get_editor_command():
|
|
32
|
+
return os.environ.get("EDITOR", "vim")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_entry_for_edit(entry: TimeEntry) -> str:
|
|
36
|
+
"""Format a time entry as YAML for editing"""
|
|
37
|
+
# Convert to dict and format as YAML
|
|
38
|
+
entry_dict = entry.model_dump(mode="json")
|
|
39
|
+
return yaml.dump(entry_dict)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _parse_edited_entry(content: str) -> TimeEntry:
|
|
43
|
+
"""Parse edited YAML content back into a TimeEntry"""
|
|
44
|
+
try:
|
|
45
|
+
data = yaml.safe_load(content)
|
|
46
|
+
return TimeEntry(**data)
|
|
47
|
+
except Exception as e:
|
|
48
|
+
raise click.ClickException(f"Invalid entry format: {str(e)}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group()
|
|
52
|
+
@click.version_option()
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def cli(ctx): ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@cli.command()
|
|
58
|
+
@click.argument("project", required=True, type=str)
|
|
59
|
+
@click.option("--tag", multiple=True)
|
|
60
|
+
@click.argument("comment", required=False, type=str, default="")
|
|
61
|
+
@config_params
|
|
62
|
+
def start(project, tag, comment, filename):
|
|
63
|
+
"""start tracking work on a project"""
|
|
64
|
+
tts = TimeTrackingService(_get_repo_from_filename(filename))
|
|
65
|
+
time_entry = tts.start_tracking(project, comment=comment, tags=tag)
|
|
66
|
+
single_entry_output(time_entry)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@cli.command()
|
|
70
|
+
@config_params
|
|
71
|
+
def stop(filename):
|
|
72
|
+
"""stop tracking work on a project"""
|
|
73
|
+
tts = TimeTrackingService(_get_repo_from_filename(filename))
|
|
74
|
+
time_entry = tts.stop_tracking()
|
|
75
|
+
if time_entry:
|
|
76
|
+
single_entry_output(time_entry)
|
|
77
|
+
else:
|
|
78
|
+
print("No active time entry to stop.")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@cli.command()
|
|
82
|
+
@config_params
|
|
83
|
+
def status(filename):
|
|
84
|
+
"""displays currently tracked (if active)"""
|
|
85
|
+
tts = TimeTrackingService(_get_repo_from_filename(filename))
|
|
86
|
+
time_entry = tts.get_active_entry()
|
|
87
|
+
if time_entry:
|
|
88
|
+
single_entry_output(time_entry)
|
|
89
|
+
else:
|
|
90
|
+
print("No active time entry.")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@cli.command()
|
|
94
|
+
@click.argument("id", required=True, type=str)
|
|
95
|
+
@config_params
|
|
96
|
+
def edit(id, filename):
|
|
97
|
+
"""edit a time entry using the system editor"""
|
|
98
|
+
tts = TimeTrackingService(_get_repo_from_filename(filename))
|
|
99
|
+
|
|
100
|
+
# Get the entry to edit
|
|
101
|
+
try:
|
|
102
|
+
if entries := tts.list_entries(EntryListFilter(id=id)):
|
|
103
|
+
if len(entries) > 1:
|
|
104
|
+
raise IndexError
|
|
105
|
+
entry = entries[0]
|
|
106
|
+
else:
|
|
107
|
+
raise KeyError
|
|
108
|
+
except KeyError:
|
|
109
|
+
raise click.ClickException(f"No entry found with id {id}")
|
|
110
|
+
except IndexError:
|
|
111
|
+
raise click.ClickException(f"Multiple records found starting with id {id}")
|
|
112
|
+
|
|
113
|
+
# Create temp file with entry content
|
|
114
|
+
with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w+", delete=False) as tmp:
|
|
115
|
+
tmp.write(_format_entry_for_edit(entry))
|
|
116
|
+
tmp.flush()
|
|
117
|
+
tmp_path = tmp.name
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# Open editor
|
|
121
|
+
editor = _get_editor_command()
|
|
122
|
+
subprocess.run([editor, tmp_path], check=True)
|
|
123
|
+
|
|
124
|
+
# Read and parse edited content
|
|
125
|
+
with open(tmp_path, "r") as f:
|
|
126
|
+
edited_content = f.read()
|
|
127
|
+
updated_entry = _parse_edited_entry(edited_content)
|
|
128
|
+
|
|
129
|
+
# Save the updated entry
|
|
130
|
+
tts.update_entry(updated_entry)
|
|
131
|
+
single_entry_output(updated_entry)
|
|
132
|
+
|
|
133
|
+
finally:
|
|
134
|
+
# Clean up temp file
|
|
135
|
+
os.unlink(tmp_path)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@cli.command()
|
|
139
|
+
@click.argument(
|
|
140
|
+
"time_period",
|
|
141
|
+
required=False,
|
|
142
|
+
type=click.Choice(["today", "week", "month", ""]),
|
|
143
|
+
default="",
|
|
144
|
+
)
|
|
145
|
+
@click.option("--start_date")
|
|
146
|
+
@click.option("--end_date")
|
|
147
|
+
@click.option("--tag", multiple=True)
|
|
148
|
+
@click.option("--project", multiple=True)
|
|
149
|
+
@click.option("--format")
|
|
150
|
+
@config_params
|
|
151
|
+
def list(time_period, start_date, end_date, tag, project, format, filename):
|
|
152
|
+
"""display list of time entries for a time period"""
|
|
153
|
+
tts = TimeTrackingService(_get_repo_from_filename(filename))
|
|
154
|
+
|
|
155
|
+
filter_params = {}
|
|
156
|
+
if time_period:
|
|
157
|
+
filter_params["time_period"] = time_period
|
|
158
|
+
if start_date:
|
|
159
|
+
filter_params["start_date"] = datetime.strptime(start_date, "%Y-%m-%d").date()
|
|
160
|
+
if end_date:
|
|
161
|
+
filter_params["end_date"] = datetime.strptime(end_date, "%Y-%m-%d").date()
|
|
162
|
+
if tag:
|
|
163
|
+
filter_params["tags"] = set(tag)
|
|
164
|
+
if project:
|
|
165
|
+
filter_params["projects"] = set(project)
|
|
166
|
+
if format:
|
|
167
|
+
filter_params["output_format"] = format
|
|
168
|
+
|
|
169
|
+
filter = EntryListFilter(**filter_params)
|
|
170
|
+
time_list = tts.list_entries(filter=filter)
|
|
171
|
+
list_output(time_list)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from datetime import datetime, date, timedelta
|
|
2
|
+
from uuid import uuid4
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
import humanize
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TimeEntry(BaseModel):
|
|
10
|
+
id: str = Field(default_factory=lambda: uuid4().hex)
|
|
11
|
+
start_time: datetime
|
|
12
|
+
end_time: datetime | None = None
|
|
13
|
+
project: str
|
|
14
|
+
tags: set[str] = Field(default_factory=set)
|
|
15
|
+
comment: str = ""
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def humanized_duration(self):
|
|
19
|
+
td = (
|
|
20
|
+
self.end_time - self.start_time
|
|
21
|
+
if self.end_time
|
|
22
|
+
else datetime.now().astimezone() - self.start_time
|
|
23
|
+
)
|
|
24
|
+
return humanize.precisedelta(
|
|
25
|
+
td,
|
|
26
|
+
suppress=("seconds", "milliseconds", "microseconds"),
|
|
27
|
+
minimum_unit="hours",
|
|
28
|
+
format="%0.1f",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _get_naive_time(ts: datetime) -> datetime | None:
|
|
33
|
+
"""remove the tzinfo so we get the original hour"""
|
|
34
|
+
return ts.replace(tzinfo=None) if ts else None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def naive_start_time(self):
|
|
38
|
+
return self._get_naive_time(self.start_time)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def naive_end_time(self):
|
|
42
|
+
return self._get_naive_time(self.end_time)
|
|
43
|
+
|
|
44
|
+
def stop(self, end_time: datetime = None):
|
|
45
|
+
if self.end_time is None or (
|
|
46
|
+
self.end_time is not None and self.end_time < self.start_time
|
|
47
|
+
):
|
|
48
|
+
self.end_time = end_time or datetime.now().astimezone()
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError("already stopped")
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def duration(self):
|
|
54
|
+
return (
|
|
55
|
+
self.end_time - self.start_time
|
|
56
|
+
if self.end_time
|
|
57
|
+
else datetime.now().astimezone() - self.start_time
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class EntryListFilter(BaseModel):
|
|
62
|
+
id: str = ""
|
|
63
|
+
projects: set[str] = Field(default_factory=set)
|
|
64
|
+
start_date: date | None = None
|
|
65
|
+
end_date: date | None = None
|
|
66
|
+
tags: set[str] = Field(default_factory=set)
|
|
67
|
+
time_period: Literal["today", "week", "month"] | None = None
|
|
68
|
+
output_format: str | None = None
|
|
69
|
+
|
|
70
|
+
def __init__(self, **data):
|
|
71
|
+
super().__init__(**data)
|
|
72
|
+
if self.time_period:
|
|
73
|
+
self._apply_time_period()
|
|
74
|
+
|
|
75
|
+
def _apply_time_period(self):
|
|
76
|
+
"""Apply date filters based on time period"""
|
|
77
|
+
now = datetime.now()
|
|
78
|
+
if self.time_period == "today":
|
|
79
|
+
self.start_date = now.date()
|
|
80
|
+
elif self.time_period == "week":
|
|
81
|
+
self.start_date = now.date() - timedelta(
|
|
82
|
+
days=now.date().weekday()
|
|
83
|
+
) # Monday
|
|
84
|
+
elif self.time_period == "month":
|
|
85
|
+
self.start_date = now.date() - timedelta(
|
|
86
|
+
days=(now.date().day - 1)
|
|
87
|
+
) # 1st of current month
|
|
File without changes
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from datetime import date
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from ..models import TimeEntry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ABBR_ID_LENGTH = 4
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def single_entry_output(entry: TimeEntry):
|
|
11
|
+
table = Table(title="Time Entry")
|
|
12
|
+
table.add_column("field")
|
|
13
|
+
table.add_column("value")
|
|
14
|
+
table.add_row(
|
|
15
|
+
"ID",
|
|
16
|
+
f"[magenta]{entry.id[0:ABBR_ID_LENGTH]}[/magenta]{entry.id[ABBR_ID_LENGTH:]}",
|
|
17
|
+
)
|
|
18
|
+
table.add_row(
|
|
19
|
+
"start date/time", f"[cyan]{entry.naive_start_time:%Y-%m-%d %H:%M:%S}[/cyan]"
|
|
20
|
+
)
|
|
21
|
+
table.add_row(
|
|
22
|
+
"end time",
|
|
23
|
+
f"[magenta]{entry.naive_end_time:%H:%M:%S}[/magenta]"
|
|
24
|
+
if entry.end_time
|
|
25
|
+
else "-",
|
|
26
|
+
)
|
|
27
|
+
table.add_row("duration", f"[cyan]{entry.humanized_duration}[/cyan]")
|
|
28
|
+
table.add_row("project", f"[green]{entry.project}[/green]")
|
|
29
|
+
table.add_row("comments", f"[blue]{entry.comment}[/blue]")
|
|
30
|
+
table.add_row("tags", "[red]" + ", ".join(tag for tag in entry.tags) + "[/red]")
|
|
31
|
+
console = Console()
|
|
32
|
+
console.print(table)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def list_output(entry_list: list[TimeEntry]):
|
|
36
|
+
table = Table(title="Time Entries")
|
|
37
|
+
table.add_column("id", justify="left", style="#707070")
|
|
38
|
+
table.add_column("start", justify="right", style="cyan")
|
|
39
|
+
table.add_column("end", justify="right", style="magenta")
|
|
40
|
+
table.add_column("delta", style="cyan")
|
|
41
|
+
table.add_column("project", justify="left", style="green")
|
|
42
|
+
table.add_column("comments", style="blue")
|
|
43
|
+
table.add_column("tags", style="red")
|
|
44
|
+
current_date = date(1970, 1, 1)
|
|
45
|
+
for entry in entry_list:
|
|
46
|
+
if entry.start_time.date() != current_date:
|
|
47
|
+
current_date = entry.start_time.date()
|
|
48
|
+
table.add_section()
|
|
49
|
+
table.add_row("", f"{current_date:%Y-%m-%d}", style="yellow")
|
|
50
|
+
table.add_row(
|
|
51
|
+
f"{entry.id[0:ABBR_ID_LENGTH]}",
|
|
52
|
+
f"{entry.naive_start_time:%H:%M:%S}",
|
|
53
|
+
f"{entry.naive_end_time:%H:%M:%S}" if entry.end_time else "-",
|
|
54
|
+
f"{entry.humanized_duration}",
|
|
55
|
+
entry.project,
|
|
56
|
+
entry.comment,
|
|
57
|
+
", ".join(tag for tag in entry.tags),
|
|
58
|
+
)
|
|
59
|
+
console = Console()
|
|
60
|
+
console.print(table)
|
|
File without changes
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from ..models import TimeEntry, EntryListFilter
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class TimeEntryRepository(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def save(self, entry: TimeEntry) -> TimeEntry:
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
def get_active_entry(self) -> TimeEntry | None:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
@abstractmethod
|
|
15
|
+
def get_by_project(self, project: str) -> list[TimeEntry]:
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def get_all(self) -> list[TimeEntry]:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
def filter(self, *, filter: EntryListFilter):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def get_entry_by_id(self) -> TimeEntry:
|
|
28
|
+
pass
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import yaml
|
|
3
|
+
from ..models import TimeEntry, EntryListFilter
|
|
4
|
+
from .time_entry_repo import TimeEntryRepository
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TimeEntryRepositoryYaml(TimeEntryRepository):
|
|
8
|
+
def __init__(self, filename: str = "timesheet.yaml"):
|
|
9
|
+
self.filename = filename
|
|
10
|
+
self._ensure_file_exists()
|
|
11
|
+
|
|
12
|
+
def _ensure_file_exists(self):
|
|
13
|
+
if not os.path.exists(self.filename):
|
|
14
|
+
self._save_data({"entries": []})
|
|
15
|
+
|
|
16
|
+
def _load_data(self) -> list[TimeEntry]:
|
|
17
|
+
with open(self.filename, "r") as f:
|
|
18
|
+
return yaml.load(f, yaml.FullLoader)
|
|
19
|
+
|
|
20
|
+
def _save_data(self, data: dict):
|
|
21
|
+
with open(self.filename, "w") as f:
|
|
22
|
+
yaml.dump(data, f)
|
|
23
|
+
|
|
24
|
+
def get_active_entry(self) -> TimeEntry | None:
|
|
25
|
+
data = self._load_data()
|
|
26
|
+
active = next((e for e in data["entries"] if not e.get("end_time")), None)
|
|
27
|
+
return TimeEntry(**active) if active else None
|
|
28
|
+
|
|
29
|
+
def get_all(self):
|
|
30
|
+
data = self._load_data()
|
|
31
|
+
return [TimeEntry(**entry) for entry in data["entries"]]
|
|
32
|
+
|
|
33
|
+
def get_by_project(self, project):
|
|
34
|
+
data = self._load_data()
|
|
35
|
+
return [
|
|
36
|
+
TimeEntry(**entry)
|
|
37
|
+
for entry in data["entries"]
|
|
38
|
+
if entry["project"] == project
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
def get_entry_by_id(self, id: str):
|
|
42
|
+
data = self._load_data()
|
|
43
|
+
for entry in data["entries"]:
|
|
44
|
+
if entry["id"] == id:
|
|
45
|
+
return TimeEntry(**entry)
|
|
46
|
+
raise KeyError("record id not found")
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _project_matching(filter_projects: set[str], project: str) -> bool:
|
|
50
|
+
for f_proj in filter_projects:
|
|
51
|
+
if (
|
|
52
|
+
f_proj[-1] in "*+." and project.startswith(f_proj[0:-1])
|
|
53
|
+
) or f_proj == project:
|
|
54
|
+
return True
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
def _check_against_filter(self, filter: EntryListFilter, entry: TimeEntry) -> bool:
|
|
58
|
+
conditions = [
|
|
59
|
+
# id filter
|
|
60
|
+
(not filter.id or entry.id.startswith(filter.id)),
|
|
61
|
+
# Project filters
|
|
62
|
+
(
|
|
63
|
+
not filter.projects
|
|
64
|
+
or self._project_matching(filter.projects, entry.project)
|
|
65
|
+
),
|
|
66
|
+
# Date filters
|
|
67
|
+
(not filter.start_date or entry.start_time.date() >= filter.start_date),
|
|
68
|
+
(
|
|
69
|
+
not filter.end_date
|
|
70
|
+
or (entry.end_time and entry.end_time.date() <= filter.end_date)
|
|
71
|
+
),
|
|
72
|
+
# Tag filter
|
|
73
|
+
(not filter.tags or any(tag in entry.tags for tag in filter.tags)),
|
|
74
|
+
]
|
|
75
|
+
return all(conditions)
|
|
76
|
+
|
|
77
|
+
def filter(self, *, filter: EntryListFilter | None = None) -> list[TimeEntry]:
|
|
78
|
+
time_entries = self.get_all()
|
|
79
|
+
if filter is None:
|
|
80
|
+
return time_entries
|
|
81
|
+
return [
|
|
82
|
+
entry for entry in time_entries if self._check_against_filter(filter, entry)
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def save(self, entry: TimeEntry) -> None:
|
|
86
|
+
data = self._load_data()
|
|
87
|
+
entry_dict = entry.model_dump(mode="json")
|
|
88
|
+
found = False
|
|
89
|
+
entries = []
|
|
90
|
+
for e in data["entries"]:
|
|
91
|
+
if e["id"] == entry.id:
|
|
92
|
+
entries.append(entry_dict)
|
|
93
|
+
found = True
|
|
94
|
+
else:
|
|
95
|
+
entries.append(e)
|
|
96
|
+
if not found:
|
|
97
|
+
entries.append(entry_dict)
|
|
98
|
+
data["entries"] = entries
|
|
99
|
+
self._save_data(data)
|