clockify-unofficial-cli 0.2.0__py3-none-any.whl
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.
- clockify_unofficial_cli/__init__.py +3 -0
- clockify_unofficial_cli/__main__.py +3 -0
- clockify_unofficial_cli/_version.py +3 -0
- clockify_unofficial_cli/auth/__init__.py +0 -0
- clockify_unofficial_cli/auth/credentials.py +70 -0
- clockify_unofficial_cli/auth/env_store.py +26 -0
- clockify_unofficial_cli/auth/file_store.py +49 -0
- clockify_unofficial_cli/auth/keyring_store.py +62 -0
- clockify_unofficial_cli/auth/resolver.py +34 -0
- clockify_unofficial_cli/commands/__init__.py +0 -0
- clockify_unofficial_cli/commands/auth.py +153 -0
- clockify_unofficial_cli/commands/client.py +116 -0
- clockify_unofficial_cli/commands/config.py +50 -0
- clockify_unofficial_cli/commands/custom_field.py +130 -0
- clockify_unofficial_cli/commands/entry.py +275 -0
- clockify_unofficial_cli/commands/group.py +89 -0
- clockify_unofficial_cli/commands/project.py +174 -0
- clockify_unofficial_cli/commands/tag.py +107 -0
- clockify_unofficial_cli/commands/task.py +194 -0
- clockify_unofficial_cli/commands/timer_shortcuts.py +130 -0
- clockify_unofficial_cli/commands/user.py +45 -0
- clockify_unofficial_cli/commands/workspace.py +68 -0
- clockify_unofficial_cli/config/__init__.py +0 -0
- clockify_unofficial_cli/config/paths.py +25 -0
- clockify_unofficial_cli/config/settings.py +75 -0
- clockify_unofficial_cli/config/store.py +56 -0
- clockify_unofficial_cli/main.py +131 -0
- clockify_unofficial_cli/output/__init__.py +0 -0
- clockify_unofficial_cli/output/columns.py +88 -0
- clockify_unofficial_cli/output/formats.py +82 -0
- clockify_unofficial_cli/output/registry.py +29 -0
- clockify_unofficial_cli/output/renderer.py +68 -0
- clockify_unofficial_cli/runtime/__init__.py +0 -0
- clockify_unofficial_cli/runtime/client_factory.py +42 -0
- clockify_unofficial_cli/runtime/context.py +99 -0
- clockify_unofficial_cli/runtime/errors.py +89 -0
- clockify_unofficial_cli/runtime/exit_codes.py +15 -0
- clockify_unofficial_cli/runtime/params.py +39 -0
- clockify_unofficial_cli/runtime/prompts.py +24 -0
- clockify_unofficial_cli/services/__init__.py +0 -0
- clockify_unofficial_cli/services/listing.py +58 -0
- clockify_unofficial_cli/services/parsing.py +118 -0
- clockify_unofficial_cli/services/resolve.py +128 -0
- clockify_unofficial_cli/services/timer.py +126 -0
- clockify_unofficial_cli-0.2.0.dist-info/METADATA +278 -0
- clockify_unofficial_cli-0.2.0.dist-info/RECORD +49 -0
- clockify_unofficial_cli-0.2.0.dist-info/WHEEL +4 -0
- clockify_unofficial_cli-0.2.0.dist-info/entry_points.txt +2 -0
- clockify_unofficial_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# 1 and 2 are reserved by Click for generic failures and usage errors.
|
|
5
|
+
class ExitCode(IntEnum):
|
|
6
|
+
OK = 0
|
|
7
|
+
FAILURE = 1
|
|
8
|
+
USAGE = 2
|
|
9
|
+
CONFIGURATION = 3
|
|
10
|
+
AUTHENTICATION = 4
|
|
11
|
+
FORBIDDEN = 5
|
|
12
|
+
NOT_FOUND = 6
|
|
13
|
+
VALIDATION = 7
|
|
14
|
+
RATE_LIMITED = 8
|
|
15
|
+
UNAVAILABLE = 9
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parameter(field: dataclasses.Field[object], annotation: object) -> inspect.Parameter:
|
|
15
|
+
default = inspect.Parameter.empty if field.default is dataclasses.MISSING else field.default
|
|
16
|
+
return inspect.Parameter(field.name, inspect.Parameter.KEYWORD_ONLY, default=default, annotation=annotation)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Typer builds a command from the callback's signature, so every option must be a parameter. This
|
|
20
|
+
# fabricates that signature from a dataclass and hands the callback one options object instead,
|
|
21
|
+
# which keeps commands within the argument limit without dropping any flag.
|
|
22
|
+
def options_from[T, R](options: type[T]) -> Callable[[Callable[[typer.Context, T], R]], Callable[..., R]]:
|
|
23
|
+
annotations = inspect.get_annotations(options, eval_str=True)
|
|
24
|
+
context = inspect.Parameter("ctx", inspect.Parameter.KEYWORD_ONLY, annotation=typer.Context)
|
|
25
|
+
fields = dataclasses.fields(options) # type: ignore[arg-type]
|
|
26
|
+
parameters = [context, *(_parameter(field, annotations[field.name]) for field in fields)]
|
|
27
|
+
signature = inspect.Signature(parameters)
|
|
28
|
+
|
|
29
|
+
def decorate(func: Callable[[typer.Context, T], R]) -> Callable[..., R]:
|
|
30
|
+
@functools.wraps(func)
|
|
31
|
+
def wrapper(*, ctx: typer.Context, **values: object) -> R:
|
|
32
|
+
return func(ctx, options(**values))
|
|
33
|
+
|
|
34
|
+
wrapper.__signature__ = signature # type: ignore[attr-defined]
|
|
35
|
+
wrapper.__annotations__ = {parameter.name: parameter.annotation for parameter in parameters}
|
|
36
|
+
del wrapper.__wrapped__
|
|
37
|
+
return wrapper
|
|
38
|
+
|
|
39
|
+
return decorate
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
8
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Single "are you sure?" prompt shared by every destructive command.
|
|
12
|
+
# TTYs get an interactive confirm; non-TTY callers must pass --yes. A "no" reply
|
|
13
|
+
# exits OK (the user explicitly cancelled, not a failure).
|
|
14
|
+
def confirm(action: str, *, assume_yes: bool) -> None:
|
|
15
|
+
if assume_yes:
|
|
16
|
+
return
|
|
17
|
+
if not sys.stdin.isatty():
|
|
18
|
+
message = f"{action} requires --yes when run non-interactively."
|
|
19
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
20
|
+
if not typer.confirm(action, err=True):
|
|
21
|
+
raise typer.Exit(ExitCode.OK)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["confirm"]
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
8
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from collections.abc import Iterator
|
|
14
|
+
|
|
15
|
+
from clockify._pagination import Page
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ListOptions:
|
|
20
|
+
limit: int | None = None
|
|
21
|
+
page: int | None = None
|
|
22
|
+
page_size: int | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collect[T](
|
|
26
|
+
fetch_all: Callable[[], Iterator[T]],
|
|
27
|
+
fetch_page: Callable[..., Page[T]],
|
|
28
|
+
options: ListOptions,
|
|
29
|
+
) -> Iterable[T]:
|
|
30
|
+
if options.page is not None or options.page_size is not None:
|
|
31
|
+
if options.limit is not None:
|
|
32
|
+
message = "--limit cannot be combined with --page/--page-size."
|
|
33
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
34
|
+
return _paginated(fetch_page, options)
|
|
35
|
+
if options.limit is None:
|
|
36
|
+
return fetch_all()
|
|
37
|
+
return itertools.islice(fetch_all(), options.limit)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _paginated[T](
|
|
41
|
+
fetch_page: Callable[..., Page[T]],
|
|
42
|
+
options: ListOptions,
|
|
43
|
+
) -> Iterable[T]:
|
|
44
|
+
page = options.page if options.page is not None else 1
|
|
45
|
+
return fetch_page(page=page, page_size=options.page_size).items
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_from_options[T](
|
|
49
|
+
fetch_all: Callable[[], Iterator[T]],
|
|
50
|
+
fetch_page: Callable[..., Page[T]],
|
|
51
|
+
limit: int | None,
|
|
52
|
+
page: int | None,
|
|
53
|
+
page_size: int | None,
|
|
54
|
+
) -> Iterable[T]:
|
|
55
|
+
return collect(fetch_all, fetch_page, ListOptions(limit=limit, page=page, page_size=page_size))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = ["ListOptions", "collect", "list_from_options"]
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import re
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
8
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
9
|
+
|
|
10
|
+
_DURATION_PATTERN: Final[str] = r"^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$"
|
|
11
|
+
_DURATION_RE: Final[re.Pattern[str]] = re.compile(_DURATION_PATTERN)
|
|
12
|
+
|
|
13
|
+
_CLOCK_PATTERN: Final[str] = r"^(\d{1,3}):(\d{2})(?::(\d{2}))?$"
|
|
14
|
+
_CLOCK_RE: Final[re.Pattern[str]] = re.compile(_CLOCK_PATTERN)
|
|
15
|
+
|
|
16
|
+
_RELATIVE_PATTERN: Final[str] = r"^([+-])(\d+)([hm])$"
|
|
17
|
+
_RELATIVE_RE: Final[re.Pattern[str]] = re.compile(_RELATIVE_PATTERN)
|
|
18
|
+
|
|
19
|
+
_DAY_KEYWORDS: Final[frozenset[str]] = frozenset({"today", "tomorrow", "yesterday"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_duration(value: str) -> datetime.timedelta:
|
|
23
|
+
raw = value.strip()
|
|
24
|
+
if not raw:
|
|
25
|
+
message = f"Invalid duration '{value}'."
|
|
26
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
27
|
+
if ":" in raw:
|
|
28
|
+
return _parse_clock_duration(raw)
|
|
29
|
+
if _DURATION_RE.match(raw) and any(raw.endswith(unit) for unit in ("h", "m", "s")):
|
|
30
|
+
return _parse_compact_duration(raw)
|
|
31
|
+
message = f"Invalid duration '{value}'."
|
|
32
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_compact_duration(value: str) -> datetime.timedelta:
|
|
36
|
+
match = _DURATION_RE.match(value)
|
|
37
|
+
if match is None:
|
|
38
|
+
message = f"Invalid duration '{value}'."
|
|
39
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
40
|
+
return datetime.timedelta(
|
|
41
|
+
hours=int(match.group(1) or 0),
|
|
42
|
+
minutes=int(match.group(2) or 0),
|
|
43
|
+
seconds=int(match.group(3) or 0),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_clock_duration(value: str) -> datetime.timedelta:
|
|
48
|
+
match = _CLOCK_RE.match(value)
|
|
49
|
+
if match is None:
|
|
50
|
+
message = f"Invalid duration '{value}'."
|
|
51
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
52
|
+
hours, minutes, seconds = match.groups()
|
|
53
|
+
return datetime.timedelta(hours=int(hours), minutes=int(minutes), seconds=int(seconds or 0))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_instant(value: str) -> datetime.datetime:
|
|
57
|
+
raw = value.strip()
|
|
58
|
+
if not raw:
|
|
59
|
+
message = f"Invalid instant '{value}'."
|
|
60
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
61
|
+
if _CLOCK_RE.match(raw):
|
|
62
|
+
return _today_at(raw)
|
|
63
|
+
head, _, tail = raw.partition(" ")
|
|
64
|
+
lowered_head = head.casefold()
|
|
65
|
+
if lowered_head in _DAY_KEYWORDS:
|
|
66
|
+
return _day_keyword_at(lowered_head, tail or "00:00")
|
|
67
|
+
if _RELATIVE_RE.match(raw):
|
|
68
|
+
return _resolve_relative(raw)
|
|
69
|
+
return _resolve_absolute(raw)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _today_at(time_str: str) -> datetime.datetime:
|
|
73
|
+
today = datetime.datetime.now(datetime.UTC).date()
|
|
74
|
+
hour, minute = _parse_clock(time_str)
|
|
75
|
+
return datetime.datetime(today.year, today.month, today.day, hour, minute, tzinfo=datetime.UTC)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _day_keyword_at(keyword: str, time_str: str) -> datetime.datetime:
|
|
79
|
+
today = datetime.datetime.now(datetime.UTC).date()
|
|
80
|
+
if keyword == "today":
|
|
81
|
+
target = today
|
|
82
|
+
elif keyword == "tomorrow":
|
|
83
|
+
target = today + datetime.timedelta(days=1)
|
|
84
|
+
else:
|
|
85
|
+
target = today - datetime.timedelta(days=1)
|
|
86
|
+
hour, minute = _parse_clock(time_str)
|
|
87
|
+
return datetime.datetime(target.year, target.month, target.day, hour, minute, tzinfo=datetime.UTC)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _parse_clock(value: str) -> tuple[int, int]:
|
|
91
|
+
match = _CLOCK_RE.match(value.strip())
|
|
92
|
+
if match is None:
|
|
93
|
+
message = f"Invalid clock time '{value}'."
|
|
94
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
95
|
+
hours, minutes, _ = match.groups()
|
|
96
|
+
return int(hours), int(minutes)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _resolve_relative(value: str) -> datetime.datetime:
|
|
100
|
+
sign, amount, unit = _RELATIVE_RE.match(value).groups() # type: ignore[union-attr]
|
|
101
|
+
delta = datetime.timedelta(hours=int(amount)) if unit == "h" else datetime.timedelta(minutes=int(amount))
|
|
102
|
+
if sign == "-":
|
|
103
|
+
delta = -delta
|
|
104
|
+
return datetime.datetime.now(datetime.UTC) + delta
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_absolute(value: str) -> datetime.datetime:
|
|
108
|
+
try:
|
|
109
|
+
parsed = datetime.datetime.fromisoformat(value)
|
|
110
|
+
except ValueError as exc:
|
|
111
|
+
message = f"Invalid instant '{value}'."
|
|
112
|
+
raise CliError(message, exit_code=ExitCode.USAGE) from exc
|
|
113
|
+
if parsed.tzinfo is None:
|
|
114
|
+
return parsed.replace(tzinfo=datetime.UTC)
|
|
115
|
+
return parsed.astimezone(datetime.UTC)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["parse_duration", "parse_instant"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
from typing import Any
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
9
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
from clockify import ProjectId
|
|
16
|
+
|
|
17
|
+
from clockify_unofficial_cli.runtime.context import AppContext
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_HEX_CHARS: Final[frozenset[str]] = frozenset("0123456789abcdef")
|
|
21
|
+
_ID_LENGTH: Final = 24
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _looks_like_id(term: str) -> bool:
|
|
25
|
+
return len(term) == _ID_LENGTH and all(character in _HEX_CHARS for character in term.lower())
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _render_candidates(candidates: Iterable[str]) -> str:
|
|
29
|
+
rendered = ", ".join(candidates)
|
|
30
|
+
return f"candidates: {rendered}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Pattern shared by every resolve_* helper: pass a term that is already an ID,
|
|
34
|
+
# an exact case-insensitive name match, or surface a clear error.
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class Candidate:
|
|
37
|
+
id: str
|
|
38
|
+
name: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class _Resolver:
|
|
43
|
+
candidates: list[Candidate]
|
|
44
|
+
noun: str
|
|
45
|
+
|
|
46
|
+
def resolve(self, term: str) -> str:
|
|
47
|
+
if _looks_like_id(term):
|
|
48
|
+
return term
|
|
49
|
+
lowered = term.casefold()
|
|
50
|
+
matches = [candidate for candidate in self.candidates if candidate.name.casefold() == lowered]
|
|
51
|
+
if len(matches) == 1:
|
|
52
|
+
return matches[0].id
|
|
53
|
+
if not matches:
|
|
54
|
+
message = f"Unknown {self.noun} '{term}'."
|
|
55
|
+
raise CliError(message, exit_code=ExitCode.NOT_FOUND)
|
|
56
|
+
ids = [candidate.id for candidate in matches]
|
|
57
|
+
message = f"Ambiguous {self.noun} '{term}'; {_render_candidates(ids)}."
|
|
58
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _candidate_ids_from(items: Iterable[Any], *, name: Callable[[Any], str]) -> list[Candidate]:
|
|
62
|
+
return [Candidate(id=str(item.id), name=name(item)) for item in items]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def resolve(term: str, candidates: list[Candidate], *, noun: str) -> str:
|
|
66
|
+
return _Resolver(candidates=candidates, noun=noun).resolve(term)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def resolve_client(app_context: AppContext, term: str) -> str:
|
|
70
|
+
workspace = app_context.workspace()
|
|
71
|
+
candidates = _candidate_ids_from(workspace.clients.list(), name=lambda item: item.name)
|
|
72
|
+
return resolve(term, candidates, noun="client")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def resolve_project(app_context: AppContext, term: str) -> str:
|
|
76
|
+
workspace = app_context.workspace()
|
|
77
|
+
candidates = _candidate_ids_from(workspace.projects.list(), name=lambda item: item.name)
|
|
78
|
+
return resolve(term, candidates, noun="project")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_tag(app_context: AppContext, term: str) -> str:
|
|
82
|
+
workspace = app_context.workspace()
|
|
83
|
+
candidates = _candidate_ids_from(workspace.tags.list(), name=lambda item: item.name)
|
|
84
|
+
return resolve(term, candidates, noun="tag")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_custom_field(app_context: AppContext, term: str) -> str:
|
|
88
|
+
workspace = app_context.workspace()
|
|
89
|
+
candidates = _candidate_ids_from(workspace.custom_fields.list(), name=lambda item: item.name)
|
|
90
|
+
return resolve(term, candidates, noun="custom field")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_group(app_context: AppContext, term: str) -> str:
|
|
94
|
+
workspace = app_context.workspace()
|
|
95
|
+
candidates = _candidate_ids_from(workspace.user_groups.list(), name=lambda item: item.name)
|
|
96
|
+
return resolve(term, candidates, noun="group")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def resolve_task(app_context: AppContext, project_id: ProjectId, term: str) -> str:
|
|
100
|
+
workspace = app_context.workspace()
|
|
101
|
+
candidates = _candidate_ids_from(workspace.tasks.list(project_id), name=lambda item: item.name)
|
|
102
|
+
return resolve(term, candidates, noun="task")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_user(app_context: AppContext, term: str) -> str:
|
|
106
|
+
workspace = app_context.workspace()
|
|
107
|
+
candidates = _candidate_ids_from(
|
|
108
|
+
workspace.users.list(),
|
|
109
|
+
name=lambda item: item.email or item.name,
|
|
110
|
+
)
|
|
111
|
+
by_name = [
|
|
112
|
+
Candidate(id=str(item.id), name=item.name) for item in workspace.users.list() if item.email != item.name
|
|
113
|
+
]
|
|
114
|
+
candidates.extend(by_name)
|
|
115
|
+
return resolve(term, candidates, noun="user")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"Candidate",
|
|
120
|
+
"resolve",
|
|
121
|
+
"resolve_client",
|
|
122
|
+
"resolve_custom_field",
|
|
123
|
+
"resolve_group",
|
|
124
|
+
"resolve_project",
|
|
125
|
+
"resolve_tag",
|
|
126
|
+
"resolve_task",
|
|
127
|
+
"resolve_user",
|
|
128
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from clockify import TimeEntryCreate
|
|
6
|
+
from clockify import TimeEntryFilter
|
|
7
|
+
|
|
8
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
9
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
10
|
+
from clockify_unofficial_cli.services.parsing import parse_duration
|
|
11
|
+
from clockify_unofficial_cli.services.parsing import parse_instant
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from clockify import ProjectId
|
|
15
|
+
from clockify import TagId
|
|
16
|
+
from clockify import TaskId
|
|
17
|
+
|
|
18
|
+
from clockify_unofficial_cli.runtime.context import AppContext
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Per-invocation bundle so the timer service can build a Clockify payload without
|
|
22
|
+
# growing a long positional argument list every time a new flag appears.
|
|
23
|
+
@dataclass(frozen=True, slots=True)
|
|
24
|
+
class TimerRequest:
|
|
25
|
+
description: str | None
|
|
26
|
+
project_id: ProjectId | None
|
|
27
|
+
task_id: TaskId | None
|
|
28
|
+
tag_ids: tuple[TagId, ...]
|
|
29
|
+
billable: bool
|
|
30
|
+
start: datetime.datetime | None
|
|
31
|
+
end: datetime.datetime | None
|
|
32
|
+
duration: datetime.timedelta | None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now() -> datetime.datetime:
|
|
36
|
+
return datetime.datetime.now(datetime.UTC)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def start_timer(app_context: AppContext, request: TimerRequest) -> object:
|
|
40
|
+
resolved_start = request.start if request.start is not None else _now()
|
|
41
|
+
payload = TimeEntryCreate(
|
|
42
|
+
start=resolved_start,
|
|
43
|
+
end=request.end,
|
|
44
|
+
billable=request.billable or None,
|
|
45
|
+
description=request.description,
|
|
46
|
+
project_id=request.project_id,
|
|
47
|
+
task_id=request.task_id,
|
|
48
|
+
tag_ids=list(request.tag_ids) if request.tag_ids else None,
|
|
49
|
+
)
|
|
50
|
+
return app_context.workspace().time_entries.start(app_context.user_id(), payload)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def stop_timer(app_context: AppContext, *, end: datetime.datetime | None) -> object:
|
|
54
|
+
return app_context.workspace().time_entries.stop(app_context.user_id(), end=end)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def running_entry(app_context: AppContext) -> object | None:
|
|
58
|
+
entries = list(
|
|
59
|
+
app_context.workspace().time_entries.list(
|
|
60
|
+
app_context.user_id(),
|
|
61
|
+
entry_filter=TimeEntryFilter(in_progress=True),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
return entries[0] if entries else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class LogRequest:
|
|
69
|
+
description: str | None
|
|
70
|
+
project_id: ProjectId | None
|
|
71
|
+
task_id: TaskId | None
|
|
72
|
+
tag_ids: tuple[TagId, ...]
|
|
73
|
+
billable: bool
|
|
74
|
+
start: datetime.datetime | None
|
|
75
|
+
end: datetime.datetime | None
|
|
76
|
+
duration: datetime.timedelta | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def log_entry(
|
|
80
|
+
app_context: AppContext,
|
|
81
|
+
request: LogRequest,
|
|
82
|
+
) -> object:
|
|
83
|
+
if request.start is not None and request.end is not None and request.duration is None:
|
|
84
|
+
resolved_start, resolved_end = request.start, request.end
|
|
85
|
+
elif request.start is not None and request.duration is not None and request.end is None:
|
|
86
|
+
resolved_start, resolved_end = request.start, request.start + request.duration
|
|
87
|
+
elif request.end is not None and request.duration is not None and request.start is None:
|
|
88
|
+
resolved_start, resolved_end = request.end - request.duration, request.end
|
|
89
|
+
elif request.duration is not None and request.start is None and request.end is None:
|
|
90
|
+
resolved_start, resolved_end = _now() - request.duration, _now()
|
|
91
|
+
else:
|
|
92
|
+
message = "`log` needs --from/--to or --duration (exactly one pairing)."
|
|
93
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
94
|
+
payload = TimeEntryCreate(
|
|
95
|
+
start=resolved_start,
|
|
96
|
+
end=resolved_end,
|
|
97
|
+
billable=request.billable or None,
|
|
98
|
+
description=request.description,
|
|
99
|
+
project_id=request.project_id,
|
|
100
|
+
task_id=request.task_id,
|
|
101
|
+
tag_ids=list(request.tag_ids) if request.tag_ids else None,
|
|
102
|
+
)
|
|
103
|
+
return app_context.workspace().time_entries.create(payload)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_optional_duration(value: str | None) -> datetime.timedelta | None:
|
|
107
|
+
if value is None:
|
|
108
|
+
return None
|
|
109
|
+
return parse_duration(value)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_optional_instant(value: str | None) -> datetime.datetime | None:
|
|
113
|
+
if value is None:
|
|
114
|
+
return None
|
|
115
|
+
return parse_instant(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"TimerRequest",
|
|
120
|
+
"log_entry",
|
|
121
|
+
"parse_optional_duration",
|
|
122
|
+
"parse_optional_instant",
|
|
123
|
+
"running_entry",
|
|
124
|
+
"start_timer",
|
|
125
|
+
"stop_timer",
|
|
126
|
+
]
|