cctodo 0.1.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.
- cctodo/__init__.py +10 -0
- cctodo/__main__.py +3 -0
- cctodo/cli.py +569 -0
- cctodo/client.py +315 -0
- cctodo/config.py +64 -0
- cctodo/errors.py +63 -0
- cctodo-0.1.0.dist-info/METADATA +110 -0
- cctodo-0.1.0.dist-info/RECORD +12 -0
- cctodo-0.1.0.dist-info/WHEEL +5 -0
- cctodo-0.1.0.dist-info/entry_points.txt +2 -0
- cctodo-0.1.0.dist-info/licenses/LICENSE +21 -0
- cctodo-0.1.0.dist-info/top_level.txt +1 -0
cctodo/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Command line tool and Python client for ccToDo (https://www.cctodo.com/)."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from .client import DONE, IN_PROGRESS, TODO, Client, Record # noqa: E402
|
|
6
|
+
from .errors import (ApiError, CctodoError, ConfigError, Conflict, ConnectionFailed, Invalid, # noqa: E402
|
|
7
|
+
NotAllowed, NotFound, RateLimited, Unauthorized)
|
|
8
|
+
|
|
9
|
+
__all__ = ["Client", "Record", "TODO", "IN_PROGRESS", "DONE", "CctodoError", "ApiError", "ConfigError",
|
|
10
|
+
"ConnectionFailed", "Unauthorized", "NotAllowed", "NotFound", "Invalid", "Conflict", "RateLimited"]
|
cctodo/__main__.py
ADDED
cctodo/cli.py
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
"""The `cctodo` command.
|
|
2
|
+
|
|
3
|
+
cctodo mytasks
|
|
4
|
+
cctodo new -group "work group" "my task"
|
|
5
|
+
cctodo done 123
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import getpass
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import shlex
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import webbrowser
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
|
|
21
|
+
from . import __version__, config
|
|
22
|
+
from .client import DONE, IN_PROGRESS, TODO, Client
|
|
23
|
+
from .errors import CctodoError, ConfigError, Invalid, Unauthorized
|
|
24
|
+
|
|
25
|
+
STATE_MARK = {TODO: "[ ]", IN_PROGRESS: "[~]", DONE: "[x]"}
|
|
26
|
+
STATE_WORDS = {TODO: "to do", IN_PROGRESS: "in progress", DONE: "done"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Output helpers ──────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
class Style:
|
|
32
|
+
def __init__(self, enabled: bool):
|
|
33
|
+
self.enabled = enabled
|
|
34
|
+
|
|
35
|
+
def _wrap(self, code, text):
|
|
36
|
+
return f"\033[{code}m{text}\033[0m" if self.enabled else text
|
|
37
|
+
|
|
38
|
+
def bold(self, text):
|
|
39
|
+
return self._wrap("1", text)
|
|
40
|
+
|
|
41
|
+
def dim(self, text):
|
|
42
|
+
return self._wrap("2", text)
|
|
43
|
+
|
|
44
|
+
def orange(self, text):
|
|
45
|
+
return self._wrap("38;5;208", text)
|
|
46
|
+
|
|
47
|
+
def green(self, text):
|
|
48
|
+
return self._wrap("32", text)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _use_color() -> bool:
|
|
52
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("TERM") != "dumb"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _local(ts: str | None) -> str:
|
|
56
|
+
if not ts:
|
|
57
|
+
return ""
|
|
58
|
+
try:
|
|
59
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().strftime("%Y-%m-%d %H:%M")
|
|
60
|
+
except ValueError:
|
|
61
|
+
return ts
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _who(user) -> str:
|
|
65
|
+
return (user.name or user.email) if user else ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Output:
|
|
69
|
+
def __init__(self, as_json: bool):
|
|
70
|
+
self.json = as_json
|
|
71
|
+
self.s = Style(_use_color() and not as_json)
|
|
72
|
+
|
|
73
|
+
def data(self, value):
|
|
74
|
+
print(json.dumps(value, indent=2, ensure_ascii=False))
|
|
75
|
+
|
|
76
|
+
def line(self, text=""):
|
|
77
|
+
print(text)
|
|
78
|
+
|
|
79
|
+
def ok(self, text):
|
|
80
|
+
if not self.json:
|
|
81
|
+
print(self.s.green("✓ ") + text)
|
|
82
|
+
|
|
83
|
+
def task_line(self, task, group_title: str | None = None, me_id: int | None = None):
|
|
84
|
+
s = self.s
|
|
85
|
+
mark = STATE_MARK.get(task.state, "[?]")
|
|
86
|
+
if task.state == IN_PROGRESS:
|
|
87
|
+
mark = s.orange(mark)
|
|
88
|
+
extras = []
|
|
89
|
+
if task.assignee:
|
|
90
|
+
name = "me" if task.assignee.id == me_id else _who(task.assignee)
|
|
91
|
+
extras.append(f"@{name}")
|
|
92
|
+
if group_title:
|
|
93
|
+
extras.append(group_title)
|
|
94
|
+
if task.body.strip():
|
|
95
|
+
extras.append("…")
|
|
96
|
+
tail = (" " + s.dim(" · ".join(extras))) if extras else ""
|
|
97
|
+
self.line(f" {s.dim(f'#{task.id}'.rjust(6))} {mark} {task.title}{tail}")
|
|
98
|
+
|
|
99
|
+
def task_list(self, title, tasks, me_id=None, groups_by_id=None, empty="Nothing to do. 🎉"):
|
|
100
|
+
if self.json:
|
|
101
|
+
return self.data(tasks)
|
|
102
|
+
self.line(self.s.bold(title) + self.s.dim(f" ({len(tasks)})"))
|
|
103
|
+
if not tasks:
|
|
104
|
+
self.line(self.s.dim(f" {empty}"))
|
|
105
|
+
for task in tasks:
|
|
106
|
+
group = groups_by_id.get(task.group) if groups_by_id else None
|
|
107
|
+
self.task_line(task, group.title if group else None, me_id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ── Commands ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def cmd_login(args, client: Client, out: Output):
|
|
113
|
+
cfg = config.load()
|
|
114
|
+
api_url = (args.api_url or config.api_url(cfg)).rstrip("/")
|
|
115
|
+
token = args.token_value
|
|
116
|
+
if not token:
|
|
117
|
+
settings_url = f"{config.web_url(api_url)}/app/settings/#tokens"
|
|
118
|
+
print(f"Create a token under Settings → API tokens:\n {settings_url}")
|
|
119
|
+
if not args.no_browser:
|
|
120
|
+
try:
|
|
121
|
+
webbrowser.open(settings_url)
|
|
122
|
+
except webbrowser.Error:
|
|
123
|
+
pass
|
|
124
|
+
token = getpass.getpass("Paste the token: ").strip()
|
|
125
|
+
if not token:
|
|
126
|
+
raise Invalid("No token given.")
|
|
127
|
+
me = Client(token=token, api_url=api_url).me()
|
|
128
|
+
cfg.update({"token": token})
|
|
129
|
+
if api_url != config.DEFAULT_API_URL:
|
|
130
|
+
cfg["api_url"] = api_url
|
|
131
|
+
else:
|
|
132
|
+
cfg.pop("api_url", None)
|
|
133
|
+
path = config.save(cfg)
|
|
134
|
+
if out.json:
|
|
135
|
+
return out.data(me)
|
|
136
|
+
out.ok(f"Logged in as {me.email}. Token saved in {path}")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_logout(args, client, out):
|
|
140
|
+
cfg = config.load()
|
|
141
|
+
had = cfg.pop("token", None)
|
|
142
|
+
config.save(cfg)
|
|
143
|
+
out.ok("Logged out. (Revoke the token under Settings → API tokens if you no longer need it.)" if had
|
|
144
|
+
else "You weren't logged in.")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_whoami(args, client, out):
|
|
148
|
+
me = client.me()
|
|
149
|
+
if out.json:
|
|
150
|
+
return out.data(me)
|
|
151
|
+
out.line(f"{me.name} <{me.email}>" if me.name and me.name != me.email else me.email)
|
|
152
|
+
out.line(out.s.dim(f"API: {client.api_url}"))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def cmd_groups(args, client, out):
|
|
156
|
+
groups = client.groups()
|
|
157
|
+
if out.json:
|
|
158
|
+
return out.data(groups)
|
|
159
|
+
for g in groups:
|
|
160
|
+
role = "" if g.is_personal else f"{g.role} · {g.member_count} member{'s' if g.member_count != 1 else ''}"
|
|
161
|
+
pending = f"{g.pending_count} pending" if g.get("pending_count") is not None else ""
|
|
162
|
+
info = " · ".join(x for x in (pending, role) if x)
|
|
163
|
+
out.line(f" {out.s.dim(f'#{g.id}'.rjust(6))} {out.s.bold(g.title)} {out.s.dim(info)}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _group(client, name):
|
|
167
|
+
return client.personal_group() if name is None else client.find_group(name)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _show_group_tasks(client, out, group, completed=False, limit=50):
|
|
171
|
+
me_id = client.me().id
|
|
172
|
+
if completed:
|
|
173
|
+
tasks = list(client.completed_tasks(group.id, limit=limit))
|
|
174
|
+
return out.task_list(f"{group.title} · completed", tasks, me_id, empty="No completed tasks yet.")
|
|
175
|
+
out.task_list(group.title, client.tasks(group.id), me_id)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_mytasks(args, client, out):
|
|
179
|
+
_show_group_tasks(client, out, client.personal_group(), args.completed, args.limit)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_list(args, client, out):
|
|
183
|
+
if args.all:
|
|
184
|
+
groups = client.groups()
|
|
185
|
+
if out.json:
|
|
186
|
+
return out.data({g.title: client.tasks(g.id) for g in groups})
|
|
187
|
+
for i, g in enumerate(groups):
|
|
188
|
+
if i:
|
|
189
|
+
out.line()
|
|
190
|
+
_show_group_tasks(client, out, g)
|
|
191
|
+
return
|
|
192
|
+
_show_group_tasks(client, out, _group(client, args.group), args.completed, args.limit)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cmd_assigned(args, client, out):
|
|
196
|
+
groups = {g.id: g for g in client.groups()}
|
|
197
|
+
out.task_list("Assigned to me", client.assigned_to_me(), client.me().id, groups)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _edit_text(initial: str, suffix=".md") -> str:
|
|
201
|
+
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ("notepad" if sys.platform == "win32" else "vi")
|
|
202
|
+
with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False, encoding="utf-8") as f:
|
|
203
|
+
f.write(initial)
|
|
204
|
+
path = f.name
|
|
205
|
+
try:
|
|
206
|
+
subprocess.run([*shlex.split(editor), path], check=True)
|
|
207
|
+
with open(path, encoding="utf-8") as f:
|
|
208
|
+
return f.read()
|
|
209
|
+
finally:
|
|
210
|
+
os.unlink(path)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _split_title_body(text: str) -> tuple[str, str]:
|
|
214
|
+
title, _, body = text.strip().partition("\n")
|
|
215
|
+
return title.strip(), body.strip()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _resolve_assignee(client, group_id, who):
|
|
219
|
+
if who is None:
|
|
220
|
+
return None
|
|
221
|
+
if who.lower() in ("me", "myself"):
|
|
222
|
+
return client.me().id
|
|
223
|
+
if who.lower() in ("none", "nobody", "-"):
|
|
224
|
+
return None
|
|
225
|
+
return client.find_member(group_id, who).id
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def cmd_new(args, client, out):
|
|
229
|
+
group = _group(client, args.group)
|
|
230
|
+
title = " ".join(args.title).strip()
|
|
231
|
+
body = args.body or ""
|
|
232
|
+
if args.body == "-":
|
|
233
|
+
body = sys.stdin.read()
|
|
234
|
+
if args.edit or not title:
|
|
235
|
+
text = _edit_text(f"{title}\n\n{body}".strip() + "\n" if title or body else "")
|
|
236
|
+
title, body = _split_title_body(text)
|
|
237
|
+
if not title:
|
|
238
|
+
raise Invalid("A task needs a title.", field="title")
|
|
239
|
+
task = client.create_task(group.id, title, body=body, assignee_id=_resolve_assignee(client, group.id, args.assign))
|
|
240
|
+
if out.json:
|
|
241
|
+
return out.data(task)
|
|
242
|
+
out.ok(f"Added #{task.id} to {group.title}: {task.title}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def cmd_show(args, client, out):
|
|
246
|
+
task = client.task(args.id)
|
|
247
|
+
comments = client.comments(task.id)
|
|
248
|
+
if out.json:
|
|
249
|
+
return out.data({**task, "comments": comments})
|
|
250
|
+
s = out.s
|
|
251
|
+
group = next((g for g in client.groups() if g.id == task.group), None)
|
|
252
|
+
out.line(f"{s.dim(f'#{task.id}')} {s.bold(task.title)}")
|
|
253
|
+
info = [STATE_WORDS.get(task.state, task.state), group.title if group else f"group #{task.group}"]
|
|
254
|
+
if task.assignee:
|
|
255
|
+
info.append(f"assigned to {_who(task.assignee)}")
|
|
256
|
+
out.line(s.dim(" · ".join(info)))
|
|
257
|
+
created = f"Created {_local(task.created_at)}" + (f" by {_who(task.created_by)}" if task.created_by else "")
|
|
258
|
+
if task.completed_at:
|
|
259
|
+
created += f" · completed {_local(task.completed_at)}" + (f" by {_who(task.completed_by)}" if task.completed_by else "")
|
|
260
|
+
out.line(s.dim(created))
|
|
261
|
+
if task.body.strip():
|
|
262
|
+
out.line()
|
|
263
|
+
out.line(task.body.rstrip())
|
|
264
|
+
if comments:
|
|
265
|
+
out.line()
|
|
266
|
+
out.line(s.bold(f"Comments ({sum(1 + len(c.replies) for c in comments)})"))
|
|
267
|
+
for c in comments:
|
|
268
|
+
_comment_line(out, c)
|
|
269
|
+
for r in c.replies:
|
|
270
|
+
_comment_line(out, r, indent=" ")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _comment_line(out, c, indent=" "):
|
|
274
|
+
s = out.s
|
|
275
|
+
text = s.dim("Comment deleted") if c.is_deleted else c.text
|
|
276
|
+
edited = " · edited" if c.edited_at else ""
|
|
277
|
+
out.line(f"{indent}{s.dim(f'#{c.id}')} {s.bold(_who(c.author) or 'someone')} {s.dim(_local(c.created_at) + edited)}")
|
|
278
|
+
out.line(f"{indent} {text}")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _state_command(state, verb):
|
|
282
|
+
def run(args, client, out):
|
|
283
|
+
results = [client.set_state(task_id, state) for task_id in args.ids]
|
|
284
|
+
if out.json:
|
|
285
|
+
return out.data(results)
|
|
286
|
+
for task in results:
|
|
287
|
+
out.ok(f"{verb} #{task.id}: {task.title}")
|
|
288
|
+
return run
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def cmd_edit(args, client, out):
|
|
292
|
+
task = client.task(args.id)
|
|
293
|
+
title, body = args.title, args.body
|
|
294
|
+
if args.body == "-":
|
|
295
|
+
body = sys.stdin.read()
|
|
296
|
+
if title is None and body is None:
|
|
297
|
+
edited = _edit_text(f"{task.title}\n\n{task.body}".rstrip() + "\n")
|
|
298
|
+
title, body = _split_title_body(edited)
|
|
299
|
+
if (title, body) == (task.title, task.body.strip()):
|
|
300
|
+
return out.ok("No changes.")
|
|
301
|
+
task = client.update_task(task.id, title=title, body=body, expected_updated_at=task.updated_at)
|
|
302
|
+
if out.json:
|
|
303
|
+
return out.data(task)
|
|
304
|
+
out.ok(f"Saved #{task.id}: {task.title}")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def cmd_assign(args, client, out):
|
|
308
|
+
task = client.task(args.id)
|
|
309
|
+
task = client.assign(task.id, _resolve_assignee(client, task.group, args.who))
|
|
310
|
+
if out.json:
|
|
311
|
+
return out.data(task)
|
|
312
|
+
out.ok(f"#{task.id} assigned to {_who(task.assignee)}" if task.assignee else f"#{task.id} is unassigned")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cmd_mv(args, client, out):
|
|
316
|
+
if not (args.group or args.before or args.after):
|
|
317
|
+
raise Invalid("Say where to: -g GROUP, --before ID or --after ID.")
|
|
318
|
+
task = client.task(args.id)
|
|
319
|
+
if args.group:
|
|
320
|
+
target = client.find_group(args.group)
|
|
321
|
+
if target.id != task.group:
|
|
322
|
+
task = client.move_task_to_group(task.id, target.id)
|
|
323
|
+
if args.before or args.after:
|
|
324
|
+
task = client.move_task(task.id, before_id=args.before, after_id=args.after)
|
|
325
|
+
if out.json:
|
|
326
|
+
return out.data(task)
|
|
327
|
+
out.ok(f"Moved #{task.id}: {task.title}")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def cmd_rm(args, client, out):
|
|
331
|
+
tasks = [client.task(task_id) for task_id in args.ids]
|
|
332
|
+
if not args.yes:
|
|
333
|
+
if not sys.stdin.isatty():
|
|
334
|
+
raise Invalid("Add --yes to delete without asking.")
|
|
335
|
+
titles = "\n".join(f" #{t.id} {t.title}" for t in tasks)
|
|
336
|
+
answer = input(f"Delete {'these tasks' if len(tasks) > 1 else 'this task'}?\n{titles}\n[y/N] ")
|
|
337
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
338
|
+
return out.line("Kept.")
|
|
339
|
+
for task in tasks:
|
|
340
|
+
client.delete_task(task.id)
|
|
341
|
+
out.ok(f"Deleted #{task.id}: {task.title}")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def cmd_comment(args, client, out):
|
|
345
|
+
text = " ".join(args.text).strip()
|
|
346
|
+
if not text or text == "-":
|
|
347
|
+
text = sys.stdin.read().strip()
|
|
348
|
+
comment = client.add_comment(args.id, text, parent_id=args.reply_to)
|
|
349
|
+
if out.json:
|
|
350
|
+
return out.data(comment)
|
|
351
|
+
out.ok(f"Commented on #{args.id}")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def cmd_group_new(args, client, out):
|
|
355
|
+
group = client.create_group(" ".join(args.title), icon=args.icon, color=args.color)
|
|
356
|
+
if out.json:
|
|
357
|
+
return out.data(group)
|
|
358
|
+
out.ok(f"Created group #{group.id}: {group.title}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def cmd_group_rename(args, client, out):
|
|
362
|
+
group = client.find_group(args.group)
|
|
363
|
+
group = client.update_group(group.id, title=" ".join(args.title))
|
|
364
|
+
if out.json:
|
|
365
|
+
return out.data(group)
|
|
366
|
+
out.ok(f"Renamed #{group.id} to {group.title}")
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def cmd_group_members(args, client, out):
|
|
370
|
+
members = client.members(client.find_group(args.group).id)
|
|
371
|
+
if out.json:
|
|
372
|
+
return out.data(members)
|
|
373
|
+
for m in members:
|
|
374
|
+
name = f"{m.user.name} <{m.user.email}>" if m.user.name and m.user.name != m.user.email else m.user.email
|
|
375
|
+
out.line(f" {name} {out.s.dim(m.role)}")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def cmd_group_leave(args, client, out):
|
|
379
|
+
group = client.find_group(args.group)
|
|
380
|
+
client.leave_group(group.id)
|
|
381
|
+
out.ok(f"Left {group.title}")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def cmd_group_rm(args, client, out):
|
|
385
|
+
group = client.find_group(args.group)
|
|
386
|
+
if not args.yes:
|
|
387
|
+
if not sys.stdin.isatty():
|
|
388
|
+
raise Invalid("Add --yes to delete without asking.")
|
|
389
|
+
answer = input(f"Delete “{group.title}” and all its tasks for everyone? [y/N] ")
|
|
390
|
+
if answer.strip().lower() not in ("y", "yes"):
|
|
391
|
+
return out.line("Kept.")
|
|
392
|
+
client.delete_group(group.id)
|
|
393
|
+
out.ok(f"Deleted {group.title}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def cmd_invite(args, client, out):
|
|
397
|
+
group = client.find_group(args.group)
|
|
398
|
+
invite = client.invite(group.id, args.email, args.message or "")
|
|
399
|
+
if out.json:
|
|
400
|
+
return out.data(invite)
|
|
401
|
+
if invite.status == "requested":
|
|
402
|
+
out.ok(f"Asked the owners of {group.title} to invite {invite.email}")
|
|
403
|
+
else:
|
|
404
|
+
out.ok(f"Invited {invite.email} to {group.title}")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cmd_invites(args, client, out):
|
|
408
|
+
invites = client.pending_invites()
|
|
409
|
+
if out.json:
|
|
410
|
+
return out.data(invites)
|
|
411
|
+
if not invites:
|
|
412
|
+
return out.line(out.s.dim("No invites."))
|
|
413
|
+
for i in invites:
|
|
414
|
+
out.line(f" {out.s.dim(f'#{i.id}'.rjust(6))} {out.s.bold(i.group.title)} {out.s.dim('from ' + _who(i.invited_by))}")
|
|
415
|
+
out.line(out.s.dim("\nAccept with: cctodo accept <id>"))
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def cmd_accept(args, client, out):
|
|
419
|
+
group = client.accept_invite(args.id)
|
|
420
|
+
if out.json:
|
|
421
|
+
return out.data(group)
|
|
422
|
+
out.ok(f"Joined {group.title}")
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def cmd_decline(args, client, out):
|
|
426
|
+
client.decline_invite(args.id)
|
|
427
|
+
out.ok("Invite declined")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ── Parser ──────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
def _group_option(parser, help_text="group: id, title or the start of it (default: My Tasks)"):
|
|
433
|
+
parser.add_argument("-g", "-group", "--group", dest="group", metavar="GROUP", help=help_text)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
437
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
438
|
+
common.add_argument("--json", action="store_true", default=argparse.SUPPRESS, help="print raw JSON")
|
|
439
|
+
common.add_argument("--api-url", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
440
|
+
|
|
441
|
+
parser = argparse.ArgumentParser(
|
|
442
|
+
prog="cctodo", parents=[common],
|
|
443
|
+
description="ccToDo from the command line. Task ids are shown as #123.",
|
|
444
|
+
epilog='Examples:\n cctodo mytasks\n cctodo new -group "work group" "my task"\n cctodo done 123\n\n'
|
|
445
|
+
"Run `cctodo <command> -h` for a command's options.",
|
|
446
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
447
|
+
parser.add_argument("-V", "--version", action="version", version=f"cctodo {__version__}")
|
|
448
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
449
|
+
|
|
450
|
+
def add(name, func, help_text, aliases=()):
|
|
451
|
+
p = sub.add_parser(name, help=help_text, description=help_text, aliases=list(aliases), parents=[common])
|
|
452
|
+
p.set_defaults(func=func)
|
|
453
|
+
return p
|
|
454
|
+
|
|
455
|
+
p = add("login", cmd_login, "log in with an API token (Settings → API tokens)")
|
|
456
|
+
p.add_argument("--token", dest="token_value", help="the token (otherwise you're asked for it)")
|
|
457
|
+
p.add_argument("--no-browser", action="store_true", help="don't open the settings page")
|
|
458
|
+
add("logout", cmd_logout, "forget the saved token")
|
|
459
|
+
add("whoami", cmd_whoami, "show who you're logged in as")
|
|
460
|
+
|
|
461
|
+
add("groups", cmd_groups, "list your groups")
|
|
462
|
+
p = add("mytasks", cmd_mytasks, "list your My Tasks", aliases=["my"])
|
|
463
|
+
p.add_argument("-c", "--completed", action="store_true", help="completed tasks instead")
|
|
464
|
+
p.add_argument("-n", "--limit", type=int, default=50, help="max completed tasks to show (default 50)")
|
|
465
|
+
p = add("list", cmd_list, "list the pending tasks in a group", aliases=["ls"])
|
|
466
|
+
p.add_argument("group_pos", nargs="?", metavar="GROUP", help=argparse.SUPPRESS)
|
|
467
|
+
_group_option(p)
|
|
468
|
+
p.add_argument("-a", "--all", action="store_true", help="every group")
|
|
469
|
+
p.add_argument("-c", "--completed", action="store_true", help="completed tasks instead")
|
|
470
|
+
p.add_argument("-n", "--limit", type=int, default=50, help="max completed tasks to show (default 50)")
|
|
471
|
+
add("assigned", cmd_assigned, "list pending tasks assigned to you, in all groups")
|
|
472
|
+
|
|
473
|
+
p = add("new", cmd_new, "add a task", aliases=["add"])
|
|
474
|
+
p.add_argument("title", nargs="*", help="the task title (quotes optional)")
|
|
475
|
+
_group_option(p)
|
|
476
|
+
p.add_argument("-b", "--body", help="details (markdown); '-' reads them from stdin")
|
|
477
|
+
p.add_argument("-a", "--assign", metavar="WHO", help="assign to 'me' or a member's email")
|
|
478
|
+
p.add_argument("-e", "--edit", action="store_true", help="write title and details in $EDITOR")
|
|
479
|
+
p = add("show", cmd_show, "show a task with its details and comments")
|
|
480
|
+
p.add_argument("id", type=int)
|
|
481
|
+
for name, state, verb, text, aliases in [
|
|
482
|
+
("done", DONE, "Completed", "mark tasks as done", ["complete", "check"]),
|
|
483
|
+
("start", IN_PROGRESS, "Started", "mark tasks as in progress", []),
|
|
484
|
+
("reopen", TODO, "Reopened", "mark tasks as to do again", ["todo", "uncheck"]),
|
|
485
|
+
]:
|
|
486
|
+
p = add(name, _state_command(state, verb), text, aliases)
|
|
487
|
+
p.add_argument("ids", nargs="+", type=int, metavar="ID")
|
|
488
|
+
p = add("edit", cmd_edit, "edit a task's title or details (opens $EDITOR without options)")
|
|
489
|
+
p.add_argument("id", type=int)
|
|
490
|
+
p.add_argument("-t", "--title")
|
|
491
|
+
p.add_argument("-b", "--body", help="new details (markdown); '-' reads them from stdin")
|
|
492
|
+
p = add("assign", cmd_assign, "assign a task to 'me', a member's email, or 'none'")
|
|
493
|
+
p.add_argument("id", type=int)
|
|
494
|
+
p.add_argument("who")
|
|
495
|
+
p = add("mv", cmd_mv, "move a task to another group, or reorder it", aliases=["move"])
|
|
496
|
+
p.add_argument("id", type=int)
|
|
497
|
+
_group_option(p, "the group to move it to")
|
|
498
|
+
p.add_argument("--before", type=int, metavar="ID", help="place it just before this task")
|
|
499
|
+
p.add_argument("--after", type=int, metavar="ID", help="place it just after this task")
|
|
500
|
+
p = add("rm", cmd_rm, "delete tasks", aliases=["delete"])
|
|
501
|
+
p.add_argument("ids", nargs="+", type=int, metavar="ID")
|
|
502
|
+
p.add_argument("-y", "--yes", action="store_true", help="don't ask")
|
|
503
|
+
p = add("comment", cmd_comment, "comment on a task")
|
|
504
|
+
p.add_argument("id", type=int)
|
|
505
|
+
p.add_argument("text", nargs="*", help="the comment ('-' or nothing reads stdin)")
|
|
506
|
+
p.add_argument("-r", "--reply-to", type=int, metavar="COMMENT_ID")
|
|
507
|
+
|
|
508
|
+
p = add("group", None, "create, rename, leave or delete groups; list members")
|
|
509
|
+
gsub = p.add_subparsers(dest="group_command", metavar="<action>", required=True)
|
|
510
|
+
|
|
511
|
+
def gadd(name, func, help_text):
|
|
512
|
+
gp = gsub.add_parser(name, help=help_text, description=help_text, parents=[common])
|
|
513
|
+
gp.set_defaults(func=func)
|
|
514
|
+
return gp
|
|
515
|
+
|
|
516
|
+
gp = gadd("new", cmd_group_new, "create a group (you become its owner)")
|
|
517
|
+
gp.add_argument("title", nargs="+")
|
|
518
|
+
gp.add_argument("--icon", help="a Lucide icon name, e.g. briefcase")
|
|
519
|
+
gp.add_argument("--color", help="e.g. orange, blue, teal")
|
|
520
|
+
gp = gadd("rename", cmd_group_rename, "rename a group")
|
|
521
|
+
gp.add_argument("group")
|
|
522
|
+
gp.add_argument("title", nargs="+")
|
|
523
|
+
gp = gadd("members", cmd_group_members, "list a group's members")
|
|
524
|
+
gp.add_argument("group")
|
|
525
|
+
gp = gadd("leave", cmd_group_leave, "leave a group")
|
|
526
|
+
gp.add_argument("group")
|
|
527
|
+
gp = gadd("rm", cmd_group_rm, "delete a group and its tasks (owners)")
|
|
528
|
+
gp.add_argument("group")
|
|
529
|
+
gp.add_argument("-y", "--yes", action="store_true", help="don't ask")
|
|
530
|
+
|
|
531
|
+
p = add("invite", cmd_invite, "invite someone to a group by email")
|
|
532
|
+
p.add_argument("group")
|
|
533
|
+
p.add_argument("email")
|
|
534
|
+
p.add_argument("-m", "--message", help="a personal message for the invite email")
|
|
535
|
+
add("invites", cmd_invites, "list invites addressed to you")
|
|
536
|
+
p = add("accept", cmd_accept, "accept an invite")
|
|
537
|
+
p.add_argument("id", type=int)
|
|
538
|
+
p = add("decline", cmd_decline, "decline an invite")
|
|
539
|
+
p.add_argument("id", type=int)
|
|
540
|
+
return parser
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def main(argv: list[str] | None = None) -> int:
|
|
544
|
+
parser = build_parser()
|
|
545
|
+
args = parser.parse_args(argv)
|
|
546
|
+
if not getattr(args, "func", None):
|
|
547
|
+
parser.print_help()
|
|
548
|
+
return 0
|
|
549
|
+
if getattr(args, "group_pos", None) and not args.group:
|
|
550
|
+
args.group = args.group_pos
|
|
551
|
+
args.json = getattr(args, "json", False)
|
|
552
|
+
args.api_url = getattr(args, "api_url", None)
|
|
553
|
+
out = Output(args.json)
|
|
554
|
+
try:
|
|
555
|
+
client = Client(api_url=args.api_url) if args.func is not cmd_login else None
|
|
556
|
+
args.func(args, client, out)
|
|
557
|
+
except KeyboardInterrupt:
|
|
558
|
+
print(file=sys.stderr)
|
|
559
|
+
return 130
|
|
560
|
+
except (Unauthorized, ConfigError) as e: # exit 3: log in first
|
|
561
|
+
print(f"cctodo: {e}", file=sys.stderr)
|
|
562
|
+
return 3
|
|
563
|
+
except CctodoError as e:
|
|
564
|
+
print(f"cctodo: {e}", file=sys.stderr)
|
|
565
|
+
return 1
|
|
566
|
+
except subprocess.CalledProcessError as e:
|
|
567
|
+
print(f"cctodo: the editor exited with status {e.returncode}; nothing saved.", file=sys.stderr)
|
|
568
|
+
return 1
|
|
569
|
+
return 0
|
cctodo/client.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""A small client for the ccToDo REST API (https://api.cctodo.com/v1/).
|
|
2
|
+
|
|
3
|
+
from cctodo import Client
|
|
4
|
+
|
|
5
|
+
todo = Client() # token from $CCTODO_TOKEN or `cctodo login`
|
|
6
|
+
work = todo.find_group("work")
|
|
7
|
+
task = todo.create_task(work.id, "Write the report")
|
|
8
|
+
todo.complete(task.id)
|
|
9
|
+
|
|
10
|
+
Results are :class:`Record` objects: plain dicts that also allow attribute access
|
|
11
|
+
(``task.title`` as well as ``task["title"]``), exactly as the API returns them.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
from typing import Any, Iterator
|
|
21
|
+
|
|
22
|
+
from . import __version__, config
|
|
23
|
+
from .errors import BY_STATUS, ApiError, ConfigError, ConnectionFailed, Invalid, NotFound
|
|
24
|
+
|
|
25
|
+
TODO, IN_PROGRESS, DONE = "todo", "in_progress", "done"
|
|
26
|
+
PERSONAL_ALIASES = {"my tasks", "mytasks", "my", "me", "personal"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Record(dict):
|
|
30
|
+
"""A dict with attribute access; nested dicts and lists are converted too."""
|
|
31
|
+
|
|
32
|
+
def __getattr__(self, name):
|
|
33
|
+
try:
|
|
34
|
+
return self[name]
|
|
35
|
+
except KeyError:
|
|
36
|
+
raise AttributeError(name) from None
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def wrap(cls, value):
|
|
40
|
+
if isinstance(value, dict):
|
|
41
|
+
return cls({k: cls.wrap(v) for k, v in value.items()})
|
|
42
|
+
if isinstance(value, list):
|
|
43
|
+
return [cls.wrap(v) for v in value]
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Client:
|
|
48
|
+
def __init__(self, token: str | None = None, api_url: str | None = None, timeout: float = 30):
|
|
49
|
+
cfg = config.load() if token is None or api_url is None else {}
|
|
50
|
+
self.token = token or config.token(cfg)
|
|
51
|
+
self.api_url = (api_url or config.api_url(cfg)).rstrip("/")
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self._me = None
|
|
54
|
+
|
|
55
|
+
# ── Transport ───────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def request(self, method: str, path: str, data: dict | None = None, params: dict | None = None) -> Any:
|
|
58
|
+
"""Calls ``/v1/<path>`` and returns the decoded JSON (None for 204)."""
|
|
59
|
+
if not self.token:
|
|
60
|
+
raise ConfigError("No API token. Run `cctodo login`, or set CCTODO_TOKEN.")
|
|
61
|
+
url = f"{self.api_url}/v1/{path.lstrip('/')}"
|
|
62
|
+
if params:
|
|
63
|
+
url += "?" + urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
64
|
+
body = json.dumps(data).encode() if data is not None else None
|
|
65
|
+
req = urllib.request.Request(url, data=body, method=method, headers={
|
|
66
|
+
"Authorization": f"Bearer {self.token}",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
"User-Agent": f"cctodo-python/{__version__}",
|
|
69
|
+
**({"Content-Type": "application/json"} if body is not None else {}),
|
|
70
|
+
})
|
|
71
|
+
try:
|
|
72
|
+
with self._open(req) as resp:
|
|
73
|
+
raw = resp.read()
|
|
74
|
+
except urllib.error.HTTPError as e:
|
|
75
|
+
raise self._error(e.code, e.read()) from None
|
|
76
|
+
except (urllib.error.URLError, OSError) as e:
|
|
77
|
+
reason = getattr(e, "reason", e)
|
|
78
|
+
raise ConnectionFailed(f"Can't reach {self.api_url}: {reason}") from None
|
|
79
|
+
return Record.wrap(json.loads(raw)) if raw else None
|
|
80
|
+
|
|
81
|
+
def _open(self, req): # replaced in tests
|
|
82
|
+
return urllib.request.urlopen(req, timeout=self.timeout)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _error(status: int, raw: bytes) -> ApiError:
|
|
86
|
+
try:
|
|
87
|
+
payload = json.loads(raw)
|
|
88
|
+
except ValueError:
|
|
89
|
+
payload = {}
|
|
90
|
+
if not isinstance(payload, dict):
|
|
91
|
+
payload = {}
|
|
92
|
+
message = payload.get("message") or payload.get("detail") or f"HTTP {status}"
|
|
93
|
+
if status == 401:
|
|
94
|
+
message = "Not logged in, or the token was revoked. Run `cctodo login`."
|
|
95
|
+
cls = BY_STATUS.get(status, ApiError)
|
|
96
|
+
return cls(message, status=status, code=payload.get("error", ""), field=payload.get("field"))
|
|
97
|
+
|
|
98
|
+
def _get(self, path, **params):
|
|
99
|
+
return self.request("GET", path, params=params or None)
|
|
100
|
+
|
|
101
|
+
def _post(self, path, data=None):
|
|
102
|
+
return self.request("POST", path, data if data is not None else {})
|
|
103
|
+
|
|
104
|
+
def _patch(self, path, data):
|
|
105
|
+
return self.request("PATCH", path, data)
|
|
106
|
+
|
|
107
|
+
def _delete(self, path):
|
|
108
|
+
return self.request("DELETE", path)
|
|
109
|
+
|
|
110
|
+
# ── Me ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def me(self) -> Record:
|
|
113
|
+
"""You: id, email, name, personal_group_id (your My Tasks)."""
|
|
114
|
+
if self._me is None:
|
|
115
|
+
self._me = self._get("me/")
|
|
116
|
+
return self._me
|
|
117
|
+
|
|
118
|
+
def notification_settings(self) -> Record:
|
|
119
|
+
return self._get("me/notifications/")
|
|
120
|
+
|
|
121
|
+
def update_notification_settings(self, **flags: bool) -> Record:
|
|
122
|
+
return self._patch("me/notifications/", flags)
|
|
123
|
+
|
|
124
|
+
# ── Groups ──────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
def groups(self) -> list[Record]:
|
|
127
|
+
"""Your groups, My Tasks first, each with role, member_count and pending_count."""
|
|
128
|
+
return self._get("groups/")
|
|
129
|
+
|
|
130
|
+
def group(self, group_id: int) -> Record:
|
|
131
|
+
return self._get(f"groups/{group_id}/")
|
|
132
|
+
|
|
133
|
+
def personal_group(self) -> Record:
|
|
134
|
+
"""Your private My Tasks group (even if you renamed it)."""
|
|
135
|
+
return self.group(self.me().personal_group_id)
|
|
136
|
+
|
|
137
|
+
def find_group(self, name: str | int) -> Record:
|
|
138
|
+
"""A group by id, exact title, or unique title prefix (case-insensitive).
|
|
139
|
+
"My Tasks", "mytasks" and "me" also find your personal group."""
|
|
140
|
+
groups = self.groups()
|
|
141
|
+
text = str(name).strip()
|
|
142
|
+
if text.isdigit():
|
|
143
|
+
for g in groups:
|
|
144
|
+
if g.id == int(text):
|
|
145
|
+
return g
|
|
146
|
+
folded = text.casefold()
|
|
147
|
+
exact = [g for g in groups if g.title.casefold() == folded]
|
|
148
|
+
if len(exact) == 1:
|
|
149
|
+
return exact[0]
|
|
150
|
+
if not exact and folded in PERSONAL_ALIASES:
|
|
151
|
+
return next(g for g in groups if g.is_personal)
|
|
152
|
+
candidates = exact or [g for g in groups if g.title.casefold().startswith(folded)]
|
|
153
|
+
if len(candidates) == 1:
|
|
154
|
+
return candidates[0]
|
|
155
|
+
if not candidates:
|
|
156
|
+
raise NotFound(f"No group matches “{text}”.")
|
|
157
|
+
titles = ", ".join(f"{g.title} (#{g.id})" for g in candidates)
|
|
158
|
+
raise Invalid(f"“{text}” matches several groups: {titles}. Use the id.", field="group")
|
|
159
|
+
|
|
160
|
+
def create_group(self, title: str, icon: str | None = None, color: str | None = None) -> Record:
|
|
161
|
+
data = {"title": title}
|
|
162
|
+
if icon:
|
|
163
|
+
data["icon"] = icon
|
|
164
|
+
if color:
|
|
165
|
+
data["color"] = color
|
|
166
|
+
return self._post("groups/", data)
|
|
167
|
+
|
|
168
|
+
def update_group(self, group_id: int, *, title=None, icon=None, color=None) -> Record:
|
|
169
|
+
data = {k: v for k, v in {"title": title, "icon": icon, "color": color}.items() if v is not None}
|
|
170
|
+
return self._patch(f"groups/{group_id}/", data)
|
|
171
|
+
|
|
172
|
+
def delete_group(self, group_id: int) -> None:
|
|
173
|
+
self._delete(f"groups/{group_id}/")
|
|
174
|
+
|
|
175
|
+
def leave_group(self, group_id: int) -> None:
|
|
176
|
+
self._post(f"groups/{group_id}/leave/")
|
|
177
|
+
|
|
178
|
+
def members(self, group_id: int) -> list[Record]:
|
|
179
|
+
return self._get(f"groups/{group_id}/members/")
|
|
180
|
+
|
|
181
|
+
def set_role(self, group_id: int, user_id: int, role: str) -> Record:
|
|
182
|
+
"""role: "owner" or "member" (owners only)."""
|
|
183
|
+
return self._patch(f"groups/{group_id}/members/{user_id}/", {"role": role})
|
|
184
|
+
|
|
185
|
+
def remove_member(self, group_id: int, user_id: int) -> None:
|
|
186
|
+
self._delete(f"groups/{group_id}/members/{user_id}/")
|
|
187
|
+
|
|
188
|
+
def icons(self) -> Record:
|
|
189
|
+
"""Allowed group icons and colours."""
|
|
190
|
+
return self._get("meta/icons/")
|
|
191
|
+
|
|
192
|
+
# ── Invites ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def invite(self, group_id: int, email: str, message: str = "") -> Record:
|
|
195
|
+
"""Owners' invites are sent at once (status "pending"); a member's waits for an owner ("requested")."""
|
|
196
|
+
return self._post(f"groups/{group_id}/invites/", {"email": email, "message": message})
|
|
197
|
+
|
|
198
|
+
def group_invites(self, group_id: int) -> list[Record]:
|
|
199
|
+
return self._get(f"groups/{group_id}/invites/")
|
|
200
|
+
|
|
201
|
+
def pending_invites(self) -> list[Record]:
|
|
202
|
+
"""Invites to groups, addressed to you."""
|
|
203
|
+
return self._get("invites/pending/")
|
|
204
|
+
|
|
205
|
+
def accept_invite(self, invite_id: int) -> Record:
|
|
206
|
+
return self._post(f"invites/{invite_id}/accept/")
|
|
207
|
+
|
|
208
|
+
def decline_invite(self, invite_id: int) -> None:
|
|
209
|
+
self._post(f"invites/{invite_id}/decline/")
|
|
210
|
+
|
|
211
|
+
def revoke_invite(self, invite_id: int) -> None:
|
|
212
|
+
self._delete(f"invites/{invite_id}/")
|
|
213
|
+
|
|
214
|
+
# ── Tasks ───────────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
def tasks(self, group_id: int) -> list[Record]:
|
|
217
|
+
"""Pending tasks (to do and in progress) in their manual order."""
|
|
218
|
+
return self._get(f"groups/{group_id}/tasks/").results
|
|
219
|
+
|
|
220
|
+
def completed_tasks(self, group_id: int, limit: int | None = None) -> Iterator[Record]:
|
|
221
|
+
"""Completed tasks, newest first (fetched page by page)."""
|
|
222
|
+
page, count = 1, 0
|
|
223
|
+
while page:
|
|
224
|
+
data = self._get(f"groups/{group_id}/tasks/", view="completed", page=page)
|
|
225
|
+
for task in data.results:
|
|
226
|
+
if limit is not None and count >= limit:
|
|
227
|
+
return
|
|
228
|
+
count += 1
|
|
229
|
+
yield task
|
|
230
|
+
page = data.next_page
|
|
231
|
+
|
|
232
|
+
def assigned_to_me(self) -> list[Record]:
|
|
233
|
+
"""Pending tasks assigned to you, across all groups."""
|
|
234
|
+
return self._get("tasks/assigned-to-me/").results
|
|
235
|
+
|
|
236
|
+
def task(self, task_id: int) -> Record:
|
|
237
|
+
return self._get(f"tasks/{task_id}/")
|
|
238
|
+
|
|
239
|
+
def create_task(self, group_id: int, title: str, body: str = "", assignee_id: int | None = None) -> Record:
|
|
240
|
+
"""New tasks go to the bottom of the list. ``body`` is markdown."""
|
|
241
|
+
data = {"title": title, "body": body}
|
|
242
|
+
if assignee_id is not None:
|
|
243
|
+
data["assignee_id"] = assignee_id
|
|
244
|
+
return self._post(f"groups/{group_id}/tasks/", data)
|
|
245
|
+
|
|
246
|
+
def update_task(self, task_id: int, *, title: str | None = None, body: str | None = None,
|
|
247
|
+
expected_updated_at: str | None = None) -> Record:
|
|
248
|
+
"""Pass the ``updated_at`` you loaded as ``expected_updated_at`` to get
|
|
249
|
+
:class:`~cctodo.errors.Conflict` instead of overwriting someone else's edit."""
|
|
250
|
+
data = {k: v for k, v in {"title": title, "body": body,
|
|
251
|
+
"expected_updated_at": expected_updated_at}.items() if v is not None}
|
|
252
|
+
return self._patch(f"tasks/{task_id}/", data)
|
|
253
|
+
|
|
254
|
+
def set_state(self, task_id: int, state: str) -> Record:
|
|
255
|
+
"""state: "todo", "in_progress" or "done"."""
|
|
256
|
+
return self._patch(f"tasks/{task_id}/", {"state": state})
|
|
257
|
+
|
|
258
|
+
def complete(self, task_id: int) -> Record:
|
|
259
|
+
return self.set_state(task_id, DONE)
|
|
260
|
+
|
|
261
|
+
def start(self, task_id: int) -> Record:
|
|
262
|
+
return self.set_state(task_id, IN_PROGRESS)
|
|
263
|
+
|
|
264
|
+
def reopen(self, task_id: int) -> Record:
|
|
265
|
+
return self.set_state(task_id, TODO)
|
|
266
|
+
|
|
267
|
+
def assign(self, task_id: int, user_id: int | None) -> Record:
|
|
268
|
+
"""Assign to a member of the task's group, or None to unassign."""
|
|
269
|
+
return self._patch(f"tasks/{task_id}/", {"assignee_id": user_id})
|
|
270
|
+
|
|
271
|
+
def move_task(self, task_id: int, *, before_id: int | None = None, after_id: int | None = None) -> Record:
|
|
272
|
+
"""Reorder: place the task just before or just after another pending task."""
|
|
273
|
+
return self._post(f"tasks/{task_id}/move/", {"before_id": before_id, "after_id": after_id})
|
|
274
|
+
|
|
275
|
+
def move_task_to_group(self, task_id: int, group_id: int) -> Record:
|
|
276
|
+
"""Moves the task to the bottom of another group (clears the assignee if they aren't a member there)."""
|
|
277
|
+
return self._post(f"tasks/{task_id}/move-group/", {"group_id": group_id})
|
|
278
|
+
|
|
279
|
+
def delete_task(self, task_id: int) -> None:
|
|
280
|
+
self._delete(f"tasks/{task_id}/")
|
|
281
|
+
|
|
282
|
+
# ── Comments ────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
def comments(self, task_id: int) -> list[Record]:
|
|
285
|
+
"""Top-level comments, oldest first, each with its ``replies``."""
|
|
286
|
+
return self._get(f"tasks/{task_id}/comments/")
|
|
287
|
+
|
|
288
|
+
def add_comment(self, task_id: int, text: str, parent_id: int | None = None) -> Record:
|
|
289
|
+
data = {"text": text}
|
|
290
|
+
if parent_id:
|
|
291
|
+
data["parent_id"] = parent_id
|
|
292
|
+
return self._post(f"tasks/{task_id}/comments/", data)
|
|
293
|
+
|
|
294
|
+
def edit_comment(self, comment_id: int, text: str) -> Record:
|
|
295
|
+
return self._patch(f"comments/{comment_id}/", {"text": text})
|
|
296
|
+
|
|
297
|
+
def delete_comment(self, comment_id: int) -> None:
|
|
298
|
+
self._delete(f"comments/{comment_id}/")
|
|
299
|
+
|
|
300
|
+
# ── Helpers ─────────────────────────────────────────────────────────────
|
|
301
|
+
|
|
302
|
+
def find_member(self, group_id: int, who: str) -> Record:
|
|
303
|
+
"""A member of the group by email (or unique email/name prefix)."""
|
|
304
|
+
members = [m.user for m in self.members(group_id)]
|
|
305
|
+
folded = who.strip().casefold()
|
|
306
|
+
exact = [u for u in members if u.email.casefold() == folded]
|
|
307
|
+
if exact:
|
|
308
|
+
return exact[0]
|
|
309
|
+
candidates = [u for u in members if u.email.casefold().startswith(folded)
|
|
310
|
+
or (u.name or "").casefold().startswith(folded)]
|
|
311
|
+
if len(candidates) == 1:
|
|
312
|
+
return candidates[0]
|
|
313
|
+
if not candidates:
|
|
314
|
+
raise NotFound(f"No member of this group matches “{who}”.")
|
|
315
|
+
raise Invalid(f"“{who}” matches several members: {', '.join(u.email for u in candidates)}.", field="assignee")
|
cctodo/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Where the token lives: $CCTODO_TOKEN, or the config file written by `cctodo login`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
DEFAULT_API_URL = "https://api.cctodo.com"
|
|
11
|
+
DEFAULT_WEB_URL = "https://www.cctodo.com"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_path() -> Path:
|
|
15
|
+
if os.environ.get("CCTODO_CONFIG"):
|
|
16
|
+
return Path(os.environ["CCTODO_CONFIG"]).expanduser()
|
|
17
|
+
if sys.platform == "win32" and os.environ.get("APPDATA"):
|
|
18
|
+
base = Path(os.environ["APPDATA"])
|
|
19
|
+
else:
|
|
20
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
21
|
+
return base / "cctodo" / "config.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load() -> dict:
|
|
25
|
+
path = config_path()
|
|
26
|
+
try:
|
|
27
|
+
with path.open(encoding="utf-8") as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return {}
|
|
31
|
+
except (OSError, ValueError) as e:
|
|
32
|
+
from .errors import ConfigError
|
|
33
|
+
raise ConfigError(f"Can't read {path}: {e}") from e
|
|
34
|
+
return data if isinstance(data, dict) else {}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def save(data: dict) -> Path:
|
|
38
|
+
"""Writes the config readable by the current user only."""
|
|
39
|
+
path = config_path()
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
tmp = path.with_suffix(".tmp")
|
|
42
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(data, f, indent=2)
|
|
45
|
+
f.write("\n")
|
|
46
|
+
os.replace(tmp, path)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def api_url(config: dict | None = None) -> str:
|
|
51
|
+
config = load() if config is None else config
|
|
52
|
+
return (os.environ.get("CCTODO_API_URL") or config.get("api_url") or DEFAULT_API_URL).rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def web_url(api: str) -> str:
|
|
56
|
+
"""The website that belongs to an API URL (api.cctodo.com → www.cctodo.com)."""
|
|
57
|
+
if api.rstrip("/") == DEFAULT_API_URL:
|
|
58
|
+
return DEFAULT_WEB_URL
|
|
59
|
+
return api.replace("://api.", "://", 1) # e.g. http://api.localhost:8000 → http://localhost:8000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def token(config: dict | None = None) -> str | None:
|
|
63
|
+
config = load() if config is None else config
|
|
64
|
+
return os.environ.get("CCTODO_TOKEN") or config.get("token")
|
cctodo/errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Errors raised by :class:`cctodo.Client`.
|
|
2
|
+
|
|
3
|
+
The API answers errors as ``{"error": code, "message": text, "field": name}``;
|
|
4
|
+
each HTTP status maps to one exception class.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CctodoError(Exception):
|
|
11
|
+
"""Base class for everything this package raises."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConfigError(CctodoError):
|
|
15
|
+
"""No token configured, or a broken config file."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConnectionFailed(CctodoError):
|
|
19
|
+
"""The API couldn't be reached."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ApiError(CctodoError):
|
|
23
|
+
status = 0
|
|
24
|
+
|
|
25
|
+
def __init__(self, message: str, *, status: int = 0, code: str = "", field: str | None = None):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.message = message
|
|
28
|
+
self.status = status or self.status
|
|
29
|
+
self.code = code
|
|
30
|
+
self.field = field
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Unauthorized(ApiError):
|
|
34
|
+
"""401: missing, invalid or revoked token."""
|
|
35
|
+
status = 401
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotAllowed(ApiError):
|
|
39
|
+
"""403: you're a member, but not allowed to do this (e.g. owners only)."""
|
|
40
|
+
status = 403
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class NotFound(ApiError):
|
|
44
|
+
"""404: doesn't exist, or you're not a member of its group."""
|
|
45
|
+
status = 404
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Invalid(ApiError):
|
|
49
|
+
"""400: bad input. ``field`` names the offending field when known."""
|
|
50
|
+
status = 400
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Conflict(ApiError):
|
|
54
|
+
"""409: e.g. the task was changed by someone else since you loaded it."""
|
|
55
|
+
status = 409
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class RateLimited(ApiError):
|
|
59
|
+
"""429: too many requests (invites, comments, ...). Try again later."""
|
|
60
|
+
status = 429
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
BY_STATUS = {cls.status: cls for cls in (Unauthorized, NotAllowed, NotFound, Invalid, Conflict, RateLimited)}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cctodo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command line tool and Python client for ccToDo (www.cctodo.com)
|
|
5
|
+
Author-email: CloudCircus <support@cctodo.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.cctodo.com/
|
|
8
|
+
Project-URL: Source, https://github.com/RubenNorgaard/cctodo_py
|
|
9
|
+
Keywords: todo,tasks,cli,cctodo
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Topic :: Office/Business :: Scheduling
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# cctodo
|
|
19
|
+
|
|
20
|
+
The command line tool and Python client for [ccToDo](https://www.cctodo.com/), made by CloudCircus.
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pip install cctodo
|
|
24
|
+
cctodo login # paste a token from Settings → API tokens
|
|
25
|
+
cctodo mytasks
|
|
26
|
+
cctodo new -group "work group" "my task"
|
|
27
|
+
cctodo done 123
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
It uses only the Python standard library and needs Python 3.9 or newer.
|
|
31
|
+
|
|
32
|
+
## Commands
|
|
33
|
+
|
|
34
|
+
Tasks are shown with their id (`#123`), and the commands take that id. Where a command takes a group,
|
|
35
|
+
you can give its id, its title, or the start of the title (case-insensitive). Without `-g`, commands
|
|
36
|
+
use your **My Tasks**.
|
|
37
|
+
|
|
38
|
+
| Command | What it does |
|
|
39
|
+
|---|---|
|
|
40
|
+
| `cctodo mytasks` (`my`) | Your pending My Tasks. `-c` shows completed tasks instead. |
|
|
41
|
+
| `cctodo list [GROUP]` (`ls`) | A group's pending tasks. `--all` lists every group, `-c` shows completed tasks. |
|
|
42
|
+
| `cctodo assigned` | Pending tasks assigned to you, in all groups. |
|
|
43
|
+
| `cctodo groups` | Your groups, with their pending counts. |
|
|
44
|
+
| `cctodo new TITLE… [-g GROUP] [-a WHO] [-b BODY] [-e]` (`add`) | Adds a task at the bottom of the list. `-b -` reads the details from stdin; `-e` writes the title and details in `$EDITOR`. |
|
|
45
|
+
| `cctodo show ID` | A task with its details (markdown) and comments. |
|
|
46
|
+
| `cctodo done ID…` / `start ID…` / `reopen ID…` | Sets the state to done, in progress, or to do. |
|
|
47
|
+
| `cctodo edit ID [-t TITLE] [-b BODY]` | Edits a task. With no options it opens `$EDITOR`: the first line is the title and the rest is the details. It refuses to overwrite someone else's newer edit. |
|
|
48
|
+
| `cctodo assign ID WHO` | Assigns to `me`, a member's email, or `none`. |
|
|
49
|
+
| `cctodo mv ID -g GROUP` / `mv ID --before ID2` / `--after ID2` | Moves a task to another group, or reorders it. |
|
|
50
|
+
| `cctodo rm ID… [-y]` | Deletes tasks after asking you to confirm. |
|
|
51
|
+
| `cctodo comment ID TEXT… [-r COMMENT_ID]` | Adds a comment, or a reply with `-r`. |
|
|
52
|
+
| `cctodo group new/rename/members/leave/rm` | Manages groups. |
|
|
53
|
+
| `cctodo invite GROUP EMAIL [-m MESSAGE]` | Invites someone. If you're a member rather than an owner, an owner approves the invite first. |
|
|
54
|
+
| `cctodo invites` / `accept ID` / `decline ID` | Lists and answers invites addressed to you. |
|
|
55
|
+
| `cctodo whoami` / `logout` | Shows the account you're logged in as, or forgets the saved token. |
|
|
56
|
+
|
|
57
|
+
Add `--json` to any command for the raw API output. `NO_COLOR` turns colours off.
|
|
58
|
+
|
|
59
|
+
**Exit codes:**
|
|
60
|
+
- `0`: the command succeeded.
|
|
61
|
+
- `1`: an error occurred.
|
|
62
|
+
- `3`: you're not logged in, or your token was revoked.
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
`cctodo login` saves the token in `~/.config/cctodo/config.json`, readable only by you
|
|
67
|
+
(`%APPDATA%\cctodo\config.json` on Windows). You can set these environment variables instead:
|
|
68
|
+
|
|
69
|
+
- `CCTODO_TOKEN`: the API token.
|
|
70
|
+
- `CCTODO_API_URL`: the API address. It defaults to `https://api.cctodo.com`.
|
|
71
|
+
- `CCTODO_CONFIG`: a different config file.
|
|
72
|
+
|
|
73
|
+
To revoke a token, go to Settings → API tokens on www.cctodo.com.
|
|
74
|
+
|
|
75
|
+
## Python
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from cctodo import Client, Conflict
|
|
79
|
+
|
|
80
|
+
todo = Client() # the same token as the CLI (or Client(token="cct_..."))
|
|
81
|
+
work = todo.find_group("work group")
|
|
82
|
+
task = todo.create_task(work.id, "Write the report", body="Due **Friday**")
|
|
83
|
+
todo.assign(task.id, todo.me().id)
|
|
84
|
+
todo.start(task.id)
|
|
85
|
+
|
|
86
|
+
for t in todo.tasks(work.id): # pending tasks, in order
|
|
87
|
+
print(t.id, t.state, t.title) # attribute or t["title"] access
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
todo.update_task(task.id, body="…", expected_updated_at=task.updated_at)
|
|
91
|
+
except Conflict:
|
|
92
|
+
print("Someone else changed it first")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Return values:** results are the API's JSON as `Record` objects (dicts with attribute access).
|
|
96
|
+
|
|
97
|
+
**Errors:** errors raise `cctodo.NotFound` (404; this also covers groups you're not in), `NotAllowed` (403),
|
|
98
|
+
`Invalid` (400), `Conflict` (409), `RateLimited` (429) or `Unauthorized` (401). All of them subclass `cctodo.CctodoError`.
|
|
99
|
+
|
|
100
|
+
## Development
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
pip install -e .
|
|
104
|
+
python -m unittest discover -s . -t .
|
|
105
|
+
CCTODO_API_URL=http://api.localhost:8000 cctodo whoami # against a local cctodo_web
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Licence
|
|
109
|
+
|
|
110
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
cctodo/__init__.py,sha256=HPYPsUBqlu48vj5xm3CwPYijrvApbNW4lQ4h6huKlP4,572
|
|
2
|
+
cctodo/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
cctodo/cli.py,sha256=PQ2_vb6DPSXWsuJFDXhT_aC_zz494A5xQhAbu6rnpHM,22440
|
|
4
|
+
cctodo/client.py,sha256=gqvwqv4oEN0-hoUEAREM3fJ0xQ0sdvMNqwlTIiBguCk,14361
|
|
5
|
+
cctodo/config.py,sha256=xcgVziIf-WAeJLX6ezyRj_tWhhjL5jBV3vP5sX2zZiA,2110
|
|
6
|
+
cctodo/errors.py,sha256=ocuodQH29r7CKiMznIats16T0tBehxF2NY125kvC2fE,1589
|
|
7
|
+
cctodo-0.1.0.dist-info/licenses/LICENSE,sha256=G24HdYmI4MWIlEUuVqBPItNa01TMjAtlv-e_X0ia7Tc,1068
|
|
8
|
+
cctodo-0.1.0.dist-info/METADATA,sha256=a3BCG6yOqnM8xJafI_WeubuC4pzsw3Y0pIPnzEesSuU,4694
|
|
9
|
+
cctodo-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
cctodo-0.1.0.dist-info/entry_points.txt,sha256=OKSTAxNQEciOtSDur1cKE9rqGWoU56KUBkmjUEYtLj4,43
|
|
11
|
+
cctodo-0.1.0.dist-info/top_level.txt,sha256=6-z-8fTElOm8V6gkwktlo5c64GG1R5t1Fd89kTaobWU,7
|
|
12
|
+
cctodo-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CloudCircus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cctodo
|