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,130 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from clockify import CustomFieldCreate
|
|
8
|
+
from clockify import CustomFieldUpdate
|
|
9
|
+
|
|
10
|
+
from clockify_unofficial_cli.output.columns import CUSTOM_FIELDS
|
|
11
|
+
from clockify_unofficial_cli.output.renderer import many
|
|
12
|
+
from clockify_unofficial_cli.output.renderer import single
|
|
13
|
+
from clockify_unofficial_cli.runtime.context import get_app_context
|
|
14
|
+
from clockify_unofficial_cli.runtime.errors import handle_errors
|
|
15
|
+
from clockify_unofficial_cli.runtime.params import options_from
|
|
16
|
+
from clockify_unofficial_cli.runtime.prompts import confirm
|
|
17
|
+
from clockify_unofficial_cli.services.listing import list_from_options
|
|
18
|
+
from clockify_unofficial_cli.services.resolve import resolve_custom_field
|
|
19
|
+
|
|
20
|
+
APP: Final = typer.Typer(help="Manage workspace custom fields.", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@APP.command(name="list", help="List every custom field in the active workspace.")
|
|
24
|
+
@handle_errors
|
|
25
|
+
def list_custom_fields(
|
|
26
|
+
ctx: typer.Context,
|
|
27
|
+
*,
|
|
28
|
+
limit: Annotated[
|
|
29
|
+
int | None,
|
|
30
|
+
typer.Option("--limit", help="Stop after N fields; incompatible with --page/--page-size."),
|
|
31
|
+
] = None,
|
|
32
|
+
page: Annotated[
|
|
33
|
+
int | None,
|
|
34
|
+
typer.Option("--page", help="1-based page number when paging through results."),
|
|
35
|
+
] = None,
|
|
36
|
+
page_size: Annotated[
|
|
37
|
+
int | None,
|
|
38
|
+
typer.Option("--page-size", help="Page size when paging through results."),
|
|
39
|
+
] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
app_context = get_app_context(ctx)
|
|
42
|
+
workspace = app_context.workspace()
|
|
43
|
+
fields = list_from_options(workspace.custom_fields.list, workspace.custom_fields.list_page, limit, page, page_size)
|
|
44
|
+
app_context.render(many(fields, CUSTOM_FIELDS))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class _CreateArgs:
|
|
49
|
+
name: Annotated[str, typer.Option("--name", help="Field display name.")]
|
|
50
|
+
field_type: Annotated[str, typer.Option("--type", help="Clockify field type, e.g. TXT or NUMBER.")]
|
|
51
|
+
entity_type: Annotated[
|
|
52
|
+
str | None,
|
|
53
|
+
typer.Option("--entity", help="Entity the field belongs to, e.g. TIMEENTRY."),
|
|
54
|
+
] = None
|
|
55
|
+
allowed_values: Annotated[
|
|
56
|
+
list[str] | None,
|
|
57
|
+
typer.Option("--allowed-value", help="Allowed value for dropdown fields; pass multiple times."),
|
|
58
|
+
] = None
|
|
59
|
+
workspace_default_value: Annotated[
|
|
60
|
+
str | None,
|
|
61
|
+
typer.Option("--default-value", help="Default value applied to new entities."),
|
|
62
|
+
] = None
|
|
63
|
+
placeholder: Annotated[str | None, typer.Option("--placeholder", help="Placeholder shown in the input.")] = None
|
|
64
|
+
description: Annotated[str | None, typer.Option("--description", help="Free-form description.")] = None
|
|
65
|
+
status: Annotated[str | None, typer.Option("--status", help="VISIBLE, INVISIBLE or INACTIVE.")] = None
|
|
66
|
+
only_admin_can_edit: Annotated[
|
|
67
|
+
bool | None,
|
|
68
|
+
typer.Option("--admin-only/--open", help="Only admins may edit the field."),
|
|
69
|
+
] = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@APP.command(help="Create a new custom field.")
|
|
73
|
+
@handle_errors
|
|
74
|
+
@options_from(_CreateArgs)
|
|
75
|
+
def create(ctx: typer.Context, args: _CreateArgs) -> None:
|
|
76
|
+
app_context = get_app_context(ctx)
|
|
77
|
+
fields = {("type" if k == "field_type" else k): v for k, v in dataclasses.asdict(args).items() if v is not None}
|
|
78
|
+
field = app_context.workspace().custom_fields.create(CustomFieldCreate(**fields))
|
|
79
|
+
app_context.render(single(field, CUSTOM_FIELDS))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True, slots=True)
|
|
83
|
+
class _UpdateArgs:
|
|
84
|
+
term: Annotated[str, typer.Argument(help="Custom field ID or exact name.")]
|
|
85
|
+
name: Annotated[str | None, typer.Option("--name", help="New field display name.")] = None
|
|
86
|
+
allowed_values: Annotated[
|
|
87
|
+
list[str] | None,
|
|
88
|
+
typer.Option("--allowed-value", help="New dropdown values; pass multiple times."),
|
|
89
|
+
] = None
|
|
90
|
+
workspace_default_value: Annotated[str | None, typer.Option("--default-value", help="New default value.")] = None
|
|
91
|
+
placeholder: Annotated[str | None, typer.Option("--placeholder", help="New placeholder.")] = None
|
|
92
|
+
description: Annotated[str | None, typer.Option("--description", help="New description.")] = None
|
|
93
|
+
status: Annotated[str | None, typer.Option("--status", help="VISIBLE, INVISIBLE or INACTIVE.")] = None
|
|
94
|
+
required: Annotated[
|
|
95
|
+
bool | None,
|
|
96
|
+
typer.Option("--required/--optional", help="Toggle whether the field is required."),
|
|
97
|
+
] = None
|
|
98
|
+
only_admin_can_edit: Annotated[
|
|
99
|
+
bool | None,
|
|
100
|
+
typer.Option("--admin-only/--open", help="Restrict edits to admins."),
|
|
101
|
+
] = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@APP.command(help="Update an existing custom field.")
|
|
105
|
+
@handle_errors
|
|
106
|
+
@options_from(_UpdateArgs)
|
|
107
|
+
def update(ctx: typer.Context, args: _UpdateArgs) -> None:
|
|
108
|
+
app_context = get_app_context(ctx)
|
|
109
|
+
field_id = resolve_custom_field(app_context, args.term)
|
|
110
|
+
changes = {k: v for k, v in dataclasses.asdict(args).items() if v is not None and k != "term"}
|
|
111
|
+
field = app_context.workspace().custom_fields.update(field_id, CustomFieldUpdate(**changes))
|
|
112
|
+
app_context.render(single(field, CUSTOM_FIELDS))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@APP.command(help="Delete a custom field.")
|
|
116
|
+
@handle_errors
|
|
117
|
+
def delete(
|
|
118
|
+
ctx: typer.Context,
|
|
119
|
+
term: Annotated[str, typer.Argument(help="Custom field ID or exact name.")],
|
|
120
|
+
*,
|
|
121
|
+
yes: Annotated[
|
|
122
|
+
bool,
|
|
123
|
+
typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
|
|
124
|
+
] = False,
|
|
125
|
+
) -> None:
|
|
126
|
+
app_context = get_app_context(ctx)
|
|
127
|
+
confirm(f"Delete custom field '{term}'", assume_yes=yes)
|
|
128
|
+
field_id = resolve_custom_field(app_context, term)
|
|
129
|
+
app_context.workspace().custom_fields.delete(field_id)
|
|
130
|
+
app_context.notify(f"Deleted custom field '{field_id}'.")
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import datetime
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from clockify import ProjectId
|
|
9
|
+
from clockify import TagId
|
|
10
|
+
from clockify import TaskId
|
|
11
|
+
from clockify import TimeEntryCreate
|
|
12
|
+
from clockify import TimeEntryFilter
|
|
13
|
+
from clockify import TimeEntryUpdate
|
|
14
|
+
|
|
15
|
+
from clockify_unofficial_cli.output.columns import TIME_ENTRIES
|
|
16
|
+
from clockify_unofficial_cli.output.renderer import many
|
|
17
|
+
from clockify_unofficial_cli.output.renderer import single
|
|
18
|
+
from clockify_unofficial_cli.runtime.context import AppContext
|
|
19
|
+
from clockify_unofficial_cli.runtime.context import get_app_context
|
|
20
|
+
from clockify_unofficial_cli.runtime.errors import CliError
|
|
21
|
+
from clockify_unofficial_cli.runtime.errors import handle_errors
|
|
22
|
+
from clockify_unofficial_cli.runtime.exit_codes import ExitCode
|
|
23
|
+
from clockify_unofficial_cli.runtime.params import options_from
|
|
24
|
+
from clockify_unofficial_cli.runtime.prompts import confirm
|
|
25
|
+
from clockify_unofficial_cli.services.listing import list_from_options
|
|
26
|
+
from clockify_unofficial_cli.services.resolve import resolve_project
|
|
27
|
+
from clockify_unofficial_cli.services.resolve import resolve_tag
|
|
28
|
+
from clockify_unofficial_cli.services.resolve import resolve_task
|
|
29
|
+
|
|
30
|
+
APP: Final = typer.Typer(help="Manage time entries.", no_args_is_help=True)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _optional_project_id(app_context: AppContext, term: str | None) -> ProjectId | None:
|
|
34
|
+
return ProjectId(resolve_project(app_context, term)) if term else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _optional_task_id(app_context: AppContext, term: str | None, project_id: ProjectId | None) -> TaskId | None:
|
|
38
|
+
if not term or project_id is None:
|
|
39
|
+
return None
|
|
40
|
+
return TaskId(resolve_task(app_context, project_id, term))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _optional_tag_ids(app_context: AppContext, terms: tuple[str, ...] | None) -> list[TagId] | None:
|
|
44
|
+
if not terms:
|
|
45
|
+
return None
|
|
46
|
+
return [TagId(resolve_tag(app_context, term)) for term in terms]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class _ListArgs:
|
|
51
|
+
description: str | None
|
|
52
|
+
start: datetime.datetime | None
|
|
53
|
+
end: datetime.datetime | None
|
|
54
|
+
project_id: ProjectId | None
|
|
55
|
+
task_id: TaskId | None
|
|
56
|
+
tag_ids: list[TagId] | None
|
|
57
|
+
limit: int | None
|
|
58
|
+
page: int | None
|
|
59
|
+
page_size: int | None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class _ListOptions:
|
|
64
|
+
description: Annotated[
|
|
65
|
+
str | None,
|
|
66
|
+
typer.Option("--description", help="Substring filter on the entry description."),
|
|
67
|
+
] = None
|
|
68
|
+
from_: Annotated[
|
|
69
|
+
datetime.datetime | None,
|
|
70
|
+
typer.Option("--from", help="Lower bound on start, e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
|
|
71
|
+
] = None
|
|
72
|
+
to: Annotated[datetime.datetime | None, typer.Option("--to", help="Upper bound on start.")] = None
|
|
73
|
+
project: Annotated[
|
|
74
|
+
str | None,
|
|
75
|
+
typer.Option("--project", "-P", help="Filter by project ID or exact name."),
|
|
76
|
+
] = None
|
|
77
|
+
task: Annotated[
|
|
78
|
+
str | None,
|
|
79
|
+
typer.Option("--task", "-t", help="Filter by task ID or exact name; requires --project."),
|
|
80
|
+
] = None
|
|
81
|
+
tag: Annotated[
|
|
82
|
+
list[str] | None,
|
|
83
|
+
typer.Option("--tag", help="Filter by tag ID or exact name; pass multiple times."),
|
|
84
|
+
] = None
|
|
85
|
+
limit: Annotated[
|
|
86
|
+
int | None,
|
|
87
|
+
typer.Option("--limit", help="Stop after N entries; incompatible with --page/--page-size."),
|
|
88
|
+
] = None
|
|
89
|
+
page: Annotated[
|
|
90
|
+
int | None,
|
|
91
|
+
typer.Option("--page", help="1-based page number when paging through results."),
|
|
92
|
+
] = None
|
|
93
|
+
page_size: Annotated[
|
|
94
|
+
int | None,
|
|
95
|
+
typer.Option("--page-size", help="Page size when paging through results."),
|
|
96
|
+
] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@APP.command(name="list", help="List time entries for the current user.")
|
|
100
|
+
@handle_errors
|
|
101
|
+
@options_from(_ListOptions)
|
|
102
|
+
def list_entries(ctx: typer.Context, options: _ListOptions) -> None:
|
|
103
|
+
app_context = get_app_context(ctx)
|
|
104
|
+
if options.task and not options.project:
|
|
105
|
+
message = "--task requires --project because tasks are scoped to projects."
|
|
106
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
107
|
+
workspace = app_context.workspace()
|
|
108
|
+
project_id = _optional_project_id(app_context, options.project)
|
|
109
|
+
args = _ListArgs(
|
|
110
|
+
description=options.description,
|
|
111
|
+
start=options.from_,
|
|
112
|
+
end=options.to,
|
|
113
|
+
project_id=project_id,
|
|
114
|
+
task_id=_optional_task_id(app_context, options.task, project_id),
|
|
115
|
+
tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
|
|
116
|
+
limit=options.limit,
|
|
117
|
+
page=options.page,
|
|
118
|
+
page_size=options.page_size,
|
|
119
|
+
)
|
|
120
|
+
entry_filter = TimeEntryFilter(
|
|
121
|
+
description=args.description,
|
|
122
|
+
start=args.start,
|
|
123
|
+
end=args.end,
|
|
124
|
+
project=args.project_id,
|
|
125
|
+
task=args.task_id,
|
|
126
|
+
tag_ids=args.tag_ids,
|
|
127
|
+
)
|
|
128
|
+
user_id = app_context.user_id()
|
|
129
|
+
entries = list_from_options(
|
|
130
|
+
lambda: workspace.time_entries.list(user_id, entry_filter=entry_filter),
|
|
131
|
+
lambda **kwargs: workspace.time_entries.list_page(user_id, entry_filter=entry_filter, **kwargs),
|
|
132
|
+
args.limit,
|
|
133
|
+
args.page,
|
|
134
|
+
args.page_size,
|
|
135
|
+
)
|
|
136
|
+
app_context.render(many(entries, TIME_ENTRIES))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@APP.command(help="Show a time entry by ID.")
|
|
140
|
+
@handle_errors
|
|
141
|
+
def get(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
entry_id: Annotated[str, typer.Argument(help="Time entry ID.")],
|
|
144
|
+
) -> None:
|
|
145
|
+
app_context = get_app_context(ctx)
|
|
146
|
+
entry = app_context.workspace().time_entries.get(entry_id)
|
|
147
|
+
app_context.render(single(entry, TIME_ENTRIES))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True, slots=True)
|
|
151
|
+
class _CreateArgs:
|
|
152
|
+
description: str | None
|
|
153
|
+
project_id: ProjectId | None
|
|
154
|
+
task_id: TaskId | None
|
|
155
|
+
tag_ids: list[TagId] | None
|
|
156
|
+
billable: bool
|
|
157
|
+
start: datetime.datetime | None
|
|
158
|
+
end: datetime.datetime | None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True, slots=True)
|
|
162
|
+
class _CreateOptions:
|
|
163
|
+
description: Annotated[str | None, typer.Option("--description", help="Entry description.")] = None
|
|
164
|
+
project: Annotated[str | None, typer.Option("--project", "-P", help="Project ID or exact name.")] = None
|
|
165
|
+
task: Annotated[
|
|
166
|
+
str | None,
|
|
167
|
+
typer.Option("--task", "-t", help="Task ID or exact name; requires --project."),
|
|
168
|
+
] = None
|
|
169
|
+
tag: Annotated[
|
|
170
|
+
list[str] | None,
|
|
171
|
+
typer.Option("--tag", help="Tag ID or exact name; pass multiple times."),
|
|
172
|
+
] = None
|
|
173
|
+
billable: Annotated[
|
|
174
|
+
bool,
|
|
175
|
+
typer.Option("--billable/--no-billable", help="Mark the entry as billable."),
|
|
176
|
+
] = False
|
|
177
|
+
from_: Annotated[
|
|
178
|
+
datetime.datetime | None,
|
|
179
|
+
typer.Option("--from", help="Start instant; e.g. 2026-09-17T09:00 or 'yesterday 09:00'."),
|
|
180
|
+
] = None
|
|
181
|
+
to: Annotated[datetime.datetime | None, typer.Option("--to", help="End instant.")] = None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@APP.command(help="Create a finished time entry (use `start` for a running entry).")
|
|
185
|
+
@handle_errors
|
|
186
|
+
@options_from(_CreateOptions)
|
|
187
|
+
def create(ctx: typer.Context, options: _CreateOptions) -> None:
|
|
188
|
+
app_context = get_app_context(ctx)
|
|
189
|
+
if options.from_ is None or options.to is None:
|
|
190
|
+
message = "`entry create` requires both --from and --to; use `log` for open ranges."
|
|
191
|
+
raise CliError(message, exit_code=ExitCode.USAGE)
|
|
192
|
+
project_id = _optional_project_id(app_context, options.project)
|
|
193
|
+
args = _CreateArgs(
|
|
194
|
+
description=options.description,
|
|
195
|
+
project_id=project_id,
|
|
196
|
+
task_id=_optional_task_id(app_context, options.task, project_id),
|
|
197
|
+
tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
|
|
198
|
+
billable=options.billable,
|
|
199
|
+
start=options.from_,
|
|
200
|
+
end=options.to,
|
|
201
|
+
)
|
|
202
|
+
payload = TimeEntryCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
|
|
203
|
+
entry = app_context.workspace().time_entries.create(payload)
|
|
204
|
+
app_context.render(single(entry, TIME_ENTRIES))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class _UpdateArgs:
|
|
209
|
+
description: str | None
|
|
210
|
+
project_id: ProjectId | None
|
|
211
|
+
task_id: TaskId | None
|
|
212
|
+
tag_ids: list[TagId] | None
|
|
213
|
+
billable: bool | None
|
|
214
|
+
start: datetime.datetime
|
|
215
|
+
end: datetime.datetime | None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class _UpdateOptions:
|
|
220
|
+
entry_id: Annotated[str, typer.Argument(help="Time entry ID.")]
|
|
221
|
+
description: Annotated[str | None, typer.Option("--description", help="New description.")] = None
|
|
222
|
+
project: Annotated[str | None, typer.Option("--project", "-P", help="New project ID or exact name.")] = None
|
|
223
|
+
task: Annotated[
|
|
224
|
+
str | None,
|
|
225
|
+
typer.Option("--task", "-t", help="New task ID or exact name; requires --project."),
|
|
226
|
+
] = None
|
|
227
|
+
tag: Annotated[
|
|
228
|
+
list[str] | None,
|
|
229
|
+
typer.Option("--tag", help="New tag IDs/names; pass multiple times."),
|
|
230
|
+
] = None
|
|
231
|
+
billable: Annotated[bool | None, typer.Option("--billable/--no-billable", help="Toggle billable.")] = None
|
|
232
|
+
from_: Annotated[
|
|
233
|
+
datetime.datetime | None,
|
|
234
|
+
typer.Option("--from", help="New start instant; required because Clockify PUT replaces the entry."),
|
|
235
|
+
] = None
|
|
236
|
+
to: Annotated[datetime.datetime | None, typer.Option("--to", help="New end instant.")] = None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@APP.command(help="Update an existing time entry (start is required by the SDK).")
|
|
240
|
+
@handle_errors
|
|
241
|
+
@options_from(_UpdateOptions)
|
|
242
|
+
def update(ctx: typer.Context, options: _UpdateOptions) -> None:
|
|
243
|
+
app_context = get_app_context(ctx)
|
|
244
|
+
workspace = app_context.workspace()
|
|
245
|
+
existing = workspace.time_entries.get(options.entry_id)
|
|
246
|
+
project_id = _optional_project_id(app_context, options.project)
|
|
247
|
+
args = _UpdateArgs(
|
|
248
|
+
description=options.description,
|
|
249
|
+
project_id=project_id,
|
|
250
|
+
task_id=_optional_task_id(app_context, options.task, project_id),
|
|
251
|
+
tag_ids=_optional_tag_ids(app_context, tuple(options.tag) if options.tag else None),
|
|
252
|
+
billable=options.billable,
|
|
253
|
+
start=options.from_ or existing.time_interval.start,
|
|
254
|
+
end=options.to,
|
|
255
|
+
)
|
|
256
|
+
payload = TimeEntryUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
|
|
257
|
+
entry = workspace.time_entries.update(options.entry_id, payload)
|
|
258
|
+
app_context.render(single(entry, TIME_ENTRIES))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@APP.command(help="Delete a time entry.")
|
|
262
|
+
@handle_errors
|
|
263
|
+
def delete(
|
|
264
|
+
ctx: typer.Context,
|
|
265
|
+
entry_id: Annotated[str, typer.Argument(help="Time entry ID.")],
|
|
266
|
+
*,
|
|
267
|
+
yes: Annotated[
|
|
268
|
+
bool,
|
|
269
|
+
typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
|
|
270
|
+
] = False,
|
|
271
|
+
) -> None:
|
|
272
|
+
app_context = get_app_context(ctx)
|
|
273
|
+
confirm(f"Delete time entry '{entry_id}'", assume_yes=yes)
|
|
274
|
+
app_context.workspace().time_entries.delete(entry_id)
|
|
275
|
+
app_context.notify(f"Deleted time entry '{entry_id}'.")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from clockify import UserGroupCreate
|
|
8
|
+
from clockify import UserGroupUpdate
|
|
9
|
+
|
|
10
|
+
from clockify_unofficial_cli.output.columns import GROUPS
|
|
11
|
+
from clockify_unofficial_cli.output.renderer import many
|
|
12
|
+
from clockify_unofficial_cli.output.renderer import single
|
|
13
|
+
from clockify_unofficial_cli.runtime.context import get_app_context
|
|
14
|
+
from clockify_unofficial_cli.runtime.errors import handle_errors
|
|
15
|
+
from clockify_unofficial_cli.runtime.prompts import confirm
|
|
16
|
+
from clockify_unofficial_cli.services.listing import list_from_options
|
|
17
|
+
from clockify_unofficial_cli.services.resolve import resolve_group
|
|
18
|
+
|
|
19
|
+
APP: Final = typer.Typer(help="Manage workspace user groups.", no_args_is_help=True)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@APP.command(name="list", help="List every user group in the active workspace.")
|
|
23
|
+
@handle_errors
|
|
24
|
+
def list_groups(
|
|
25
|
+
ctx: typer.Context,
|
|
26
|
+
*,
|
|
27
|
+
limit: Annotated[
|
|
28
|
+
int | None,
|
|
29
|
+
typer.Option("--limit", help="Stop after N groups; incompatible with --page/--page-size."),
|
|
30
|
+
] = None,
|
|
31
|
+
page: Annotated[
|
|
32
|
+
int | None,
|
|
33
|
+
typer.Option("--page", help="1-based page number when paging through results."),
|
|
34
|
+
] = None,
|
|
35
|
+
page_size: Annotated[
|
|
36
|
+
int | None,
|
|
37
|
+
typer.Option("--page-size", help="Page size when paging through results."),
|
|
38
|
+
] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
app_context = get_app_context(ctx)
|
|
41
|
+
workspace = app_context.workspace()
|
|
42
|
+
groups = list_from_options(workspace.user_groups.list, workspace.user_groups.list_page, limit, page, page_size)
|
|
43
|
+
app_context.render(many(groups, GROUPS))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@APP.command(help="Create a new user group.")
|
|
47
|
+
@handle_errors
|
|
48
|
+
def create(ctx: typer.Context, *, name: Annotated[str, typer.Option("--name", help="Group name.")]) -> None:
|
|
49
|
+
app_context = get_app_context(ctx)
|
|
50
|
+
group = app_context.workspace().user_groups.create(UserGroupCreate(name=name))
|
|
51
|
+
app_context.render(single(group, GROUPS))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class _UpdateArgs:
|
|
56
|
+
name: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@APP.command(help="Update an existing user group.")
|
|
60
|
+
@handle_errors
|
|
61
|
+
def update(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
term: Annotated[str, typer.Argument(help="Group ID or exact name.")],
|
|
64
|
+
*,
|
|
65
|
+
name: Annotated[str, typer.Option("--name", help="New group name.")],
|
|
66
|
+
) -> None:
|
|
67
|
+
args = _UpdateArgs(name=name)
|
|
68
|
+
app_context = get_app_context(ctx)
|
|
69
|
+
group_id = resolve_group(app_context, term)
|
|
70
|
+
group = app_context.workspace().user_groups.update(group_id, UserGroupUpdate(**dataclasses.asdict(args)))
|
|
71
|
+
app_context.render(single(group, GROUPS))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@APP.command(help="Delete a user group.")
|
|
75
|
+
@handle_errors
|
|
76
|
+
def delete(
|
|
77
|
+
ctx: typer.Context,
|
|
78
|
+
term: Annotated[str, typer.Argument(help="Group ID or exact name.")],
|
|
79
|
+
*,
|
|
80
|
+
yes: Annotated[
|
|
81
|
+
bool,
|
|
82
|
+
typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
|
|
83
|
+
] = False,
|
|
84
|
+
) -> None:
|
|
85
|
+
app_context = get_app_context(ctx)
|
|
86
|
+
confirm(f"Delete user group '{term}'", assume_yes=yes)
|
|
87
|
+
group_id = resolve_group(app_context, term)
|
|
88
|
+
app_context.workspace().user_groups.delete(group_id)
|
|
89
|
+
app_context.notify(f"Deleted user group '{group_id}'.")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Annotated
|
|
4
|
+
from typing import Final
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from clockify import ClientId
|
|
8
|
+
from clockify import ProjectCreate
|
|
9
|
+
from clockify import ProjectUpdate
|
|
10
|
+
|
|
11
|
+
from clockify_unofficial_cli.output.columns import PROJECTS_LIST
|
|
12
|
+
from clockify_unofficial_cli.output.renderer import many
|
|
13
|
+
from clockify_unofficial_cli.output.renderer import single
|
|
14
|
+
from clockify_unofficial_cli.runtime.context import get_app_context
|
|
15
|
+
from clockify_unofficial_cli.runtime.errors import handle_errors
|
|
16
|
+
from clockify_unofficial_cli.runtime.params import options_from
|
|
17
|
+
from clockify_unofficial_cli.runtime.prompts import confirm
|
|
18
|
+
from clockify_unofficial_cli.services.listing import list_from_options
|
|
19
|
+
from clockify_unofficial_cli.services.resolve import resolve_client
|
|
20
|
+
from clockify_unofficial_cli.services.resolve import resolve_project
|
|
21
|
+
|
|
22
|
+
APP: Final = typer.Typer(help="Manage workspace projects.", no_args_is_help=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@APP.command(name="list", help="List every project in the active workspace.")
|
|
26
|
+
@handle_errors
|
|
27
|
+
def list_projects(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
*,
|
|
30
|
+
limit: Annotated[
|
|
31
|
+
int | None,
|
|
32
|
+
typer.Option("--limit", help="Stop after N projects; incompatible with --page/--page-size."),
|
|
33
|
+
] = None,
|
|
34
|
+
page: Annotated[
|
|
35
|
+
int | None,
|
|
36
|
+
typer.Option("--page", help="1-based page number when paging through results."),
|
|
37
|
+
] = None,
|
|
38
|
+
page_size: Annotated[
|
|
39
|
+
int | None,
|
|
40
|
+
typer.Option("--page-size", help="Page size when paging through results."),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
app_context = get_app_context(ctx)
|
|
44
|
+
workspace = app_context.workspace()
|
|
45
|
+
projects = list_from_options(workspace.projects.list, workspace.projects.list_page, limit, page, page_size)
|
|
46
|
+
app_context.render(many(projects, PROJECTS_LIST))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@APP.command(help="Show a project by ID or exact name.")
|
|
50
|
+
@handle_errors
|
|
51
|
+
def get(ctx: typer.Context, term: Annotated[str, typer.Argument(help="Project ID or exact name.")]) -> None:
|
|
52
|
+
app_context = get_app_context(ctx)
|
|
53
|
+
project_id = resolve_project(app_context, term)
|
|
54
|
+
project = app_context.workspace().projects.get(project_id)
|
|
55
|
+
app_context.render(single(project, PROJECTS_LIST))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, slots=True)
|
|
59
|
+
class _CreateArgs:
|
|
60
|
+
name: str
|
|
61
|
+
client_id: ClientId | None
|
|
62
|
+
is_public: bool | None
|
|
63
|
+
billable: bool | None
|
|
64
|
+
color: str | None
|
|
65
|
+
note: str | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class _CreateOptions:
|
|
70
|
+
name: Annotated[str, typer.Option("--name", help="Project display name.")]
|
|
71
|
+
client: Annotated[
|
|
72
|
+
str | None,
|
|
73
|
+
typer.Option("--client", help="Client ID or exact name; pass --no-client to leave it blank."),
|
|
74
|
+
] = None
|
|
75
|
+
public: Annotated[
|
|
76
|
+
bool | None,
|
|
77
|
+
typer.Option(
|
|
78
|
+
"--public/--private",
|
|
79
|
+
help="Whether the project is visible to every workspace member; omit to leave the default.",
|
|
80
|
+
),
|
|
81
|
+
] = None
|
|
82
|
+
billable: Annotated[
|
|
83
|
+
bool | None,
|
|
84
|
+
typer.Option("--billable/--no-billable", help="Default billable flag for time entries."),
|
|
85
|
+
] = None
|
|
86
|
+
color: Annotated[str | None, typer.Option("--color", help="Hex colour, e.g. #0f62fe.")] = None
|
|
87
|
+
note: Annotated[str | None, typer.Option("--note", help="Free-form note.")] = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@APP.command(help="Create a new project.")
|
|
91
|
+
@handle_errors
|
|
92
|
+
@options_from(_CreateOptions)
|
|
93
|
+
def create(ctx: typer.Context, options: _CreateOptions) -> None:
|
|
94
|
+
app_context = get_app_context(ctx)
|
|
95
|
+
client_id: ClientId | None = ClientId(resolve_client(app_context, options.client)) if options.client else None
|
|
96
|
+
args = _CreateArgs(
|
|
97
|
+
name=options.name,
|
|
98
|
+
client_id=client_id,
|
|
99
|
+
is_public=options.public,
|
|
100
|
+
billable=options.billable,
|
|
101
|
+
color=options.color,
|
|
102
|
+
note=options.note,
|
|
103
|
+
)
|
|
104
|
+
payload = ProjectCreate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
|
|
105
|
+
project = app_context.workspace().projects.create(payload)
|
|
106
|
+
app_context.render(single(project, PROJECTS_LIST))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class _UpdateArgs:
|
|
111
|
+
name: str | None
|
|
112
|
+
client_id: ClientId | None
|
|
113
|
+
is_public: bool | None
|
|
114
|
+
billable: bool | None
|
|
115
|
+
color: str | None
|
|
116
|
+
note: str | None
|
|
117
|
+
archived: bool | None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class _UpdateOptions:
|
|
122
|
+
term: Annotated[str, typer.Argument(help="Project ID or exact name.")]
|
|
123
|
+
name: Annotated[str | None, typer.Option("--name", help="New project display name.")] = None
|
|
124
|
+
client: Annotated[str | None, typer.Option("--client", help="New client ID or exact name.")] = None
|
|
125
|
+
public: Annotated[bool | None, typer.Option("--public/--private", help="Toggle the project's visibility.")] = None
|
|
126
|
+
billable: Annotated[
|
|
127
|
+
bool | None,
|
|
128
|
+
typer.Option("--billable/--no-billable", help="Default billable flag for new entries."),
|
|
129
|
+
] = None
|
|
130
|
+
color: Annotated[str | None, typer.Option("--color", help="Hex colour, e.g. #0f62fe.")] = None
|
|
131
|
+
note: Annotated[str | None, typer.Option("--note", help="New free-form note.")] = None
|
|
132
|
+
archived: Annotated[
|
|
133
|
+
bool | None,
|
|
134
|
+
typer.Option("--archived/--no-archived", help="Archive or restore the project."),
|
|
135
|
+
] = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@APP.command(help="Update an existing project.")
|
|
139
|
+
@handle_errors
|
|
140
|
+
@options_from(_UpdateOptions)
|
|
141
|
+
def update(ctx: typer.Context, options: _UpdateOptions) -> None:
|
|
142
|
+
app_context = get_app_context(ctx)
|
|
143
|
+
project_id = resolve_project(app_context, options.term)
|
|
144
|
+
client_id: ClientId | None = ClientId(resolve_client(app_context, options.client)) if options.client else None
|
|
145
|
+
args = _UpdateArgs(
|
|
146
|
+
name=options.name,
|
|
147
|
+
client_id=client_id,
|
|
148
|
+
is_public=options.public,
|
|
149
|
+
billable=options.billable,
|
|
150
|
+
color=options.color,
|
|
151
|
+
note=options.note,
|
|
152
|
+
archived=options.archived,
|
|
153
|
+
)
|
|
154
|
+
payload = ProjectUpdate(**{k: v for k, v in dataclasses.asdict(args).items() if v is not None})
|
|
155
|
+
project = app_context.workspace().projects.update(project_id, payload)
|
|
156
|
+
app_context.render(single(project, PROJECTS_LIST))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@APP.command(help="Delete a project.")
|
|
160
|
+
@handle_errors
|
|
161
|
+
def delete(
|
|
162
|
+
ctx: typer.Context,
|
|
163
|
+
term: Annotated[str, typer.Argument(help="Project ID or exact name.")],
|
|
164
|
+
*,
|
|
165
|
+
yes: Annotated[
|
|
166
|
+
bool,
|
|
167
|
+
typer.Option("--yes", "-y", help="Skip the interactive confirmation prompt."),
|
|
168
|
+
] = False,
|
|
169
|
+
) -> None:
|
|
170
|
+
app_context = get_app_context(ctx)
|
|
171
|
+
confirm(f"Delete project '{term}'", assume_yes=yes)
|
|
172
|
+
project_id = resolve_project(app_context, term)
|
|
173
|
+
app_context.workspace().projects.delete(project_id)
|
|
174
|
+
app_context.notify(f"Deleted project '{project_id}'.")
|