nextcloud-cli 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.
- nextcloud_cli/VERSION.md +1 -0
- nextcloud_cli/__init__.py +3 -0
- nextcloud_cli/__main__.py +4 -0
- nextcloud_cli/cli.py +36 -0
- nextcloud_cli/client.py +44 -0
- nextcloud_cli/commands/__init__.py +0 -0
- nextcloud_cli/commands/calendar.py +442 -0
- nextcloud_cli/commands/check.py +69 -0
- nextcloud_cli/commands/contacts.py +266 -0
- nextcloud_cli/commands/files.py +166 -0
- nextcloud_cli/commands/notes.py +119 -0
- nextcloud_cli/commands/setup.py +66 -0
- nextcloud_cli/commands/tasks.py +250 -0
- nextcloud_cli/config.py +151 -0
- nextcloud_cli/rendering.py +262 -0
- nextcloud_cli/utils.py +129 -0
- nextcloud_cli-0.1.0.dist-info/METADATA +282 -0
- nextcloud_cli-0.1.0.dist-info/RECORD +21 -0
- nextcloud_cli-0.1.0.dist-info/WHEEL +4 -0
- nextcloud_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nextcloud_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Tasks (VTODO) operations on a CalDAV task list."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from icalendar import Calendar as ICal
|
|
10
|
+
from icalendar import Todo
|
|
11
|
+
|
|
12
|
+
from nextcloud_cli.client import caldav_principal
|
|
13
|
+
from nextcloud_cli.config import load
|
|
14
|
+
from nextcloud_cli.rendering import render_status, render_tasks
|
|
15
|
+
from nextcloud_cli.utils import (
|
|
16
|
+
CONTEXT_SETTINGS,
|
|
17
|
+
fail,
|
|
18
|
+
json_option,
|
|
19
|
+
parse_datetime,
|
|
20
|
+
spinner,
|
|
21
|
+
verbose_option,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
26
|
+
def tasks() -> None:
|
|
27
|
+
"""Manage tasks (VTODO) via CalDAV."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _find_task_list(principal, name: str | None):
|
|
31
|
+
candidates = []
|
|
32
|
+
for cal in principal.calendars():
|
|
33
|
+
comps = []
|
|
34
|
+
try:
|
|
35
|
+
comps = list(cal.components or [])
|
|
36
|
+
except Exception:
|
|
37
|
+
pass
|
|
38
|
+
if "VTODO" in comps or name and cal.name == name:
|
|
39
|
+
candidates.append(cal)
|
|
40
|
+
if name:
|
|
41
|
+
for cal in candidates:
|
|
42
|
+
if cal.name == name:
|
|
43
|
+
return cal
|
|
44
|
+
fail(f"task list not found: {name}")
|
|
45
|
+
if not candidates:
|
|
46
|
+
fail("no task lists found on the server")
|
|
47
|
+
return candidates[0]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _vtodo_to_dict(component, list_name: str) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"uid": str(component.get("UID")),
|
|
53
|
+
"summary": str(component.get("SUMMARY", "")),
|
|
54
|
+
"due": component.decoded("DUE").isoformat() if component.get("DUE") else None,
|
|
55
|
+
"status": str(component.get("STATUS", "")),
|
|
56
|
+
"priority": int(component.get("PRIORITY", 0)) or None,
|
|
57
|
+
"list": list_name,
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@verbose_option
|
|
62
|
+
@json_option
|
|
63
|
+
@tasks.command("list")
|
|
64
|
+
@click.option("--list", "list_name", default=None, help="Specific task list name.")
|
|
65
|
+
@click.option("--include-completed", is_flag=True)
|
|
66
|
+
def list_(list_name: str | None, include_completed: bool, json_output: bool) -> None:
|
|
67
|
+
"""List tasks."""
|
|
68
|
+
cfg = load()
|
|
69
|
+
out: list[dict] = []
|
|
70
|
+
with spinner("Fetching tasks", json_output):
|
|
71
|
+
with caldav_principal(cfg) as principal:
|
|
72
|
+
cal = _find_task_list(principal, list_name)
|
|
73
|
+
for todo in cal.todos(include_completed=include_completed):
|
|
74
|
+
ical = ICal.from_ical(todo.data)
|
|
75
|
+
for component in ical.walk("VTODO"):
|
|
76
|
+
out.append(_vtodo_to_dict(component, cal.name))
|
|
77
|
+
render_tasks(out, json_output)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_TASK_SEARCH_FIELDS = {
|
|
81
|
+
"summary": ("summary",),
|
|
82
|
+
"description": ("description",),
|
|
83
|
+
"category": ("category",),
|
|
84
|
+
"all": ("summary", "description"),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@verbose_option
|
|
89
|
+
@json_option
|
|
90
|
+
@tasks.command()
|
|
91
|
+
@click.option("--list", "list_name", default=None, help="Specific task list name.")
|
|
92
|
+
@click.option("--query", required=True, help="Substring to match (case-insensitive, server-side).")
|
|
93
|
+
@click.option(
|
|
94
|
+
"--in",
|
|
95
|
+
"field",
|
|
96
|
+
type=click.Choice(list(_TASK_SEARCH_FIELDS.keys())),
|
|
97
|
+
default="summary",
|
|
98
|
+
help="Which iCalendar property to search (default: summary).",
|
|
99
|
+
)
|
|
100
|
+
@click.option("--include-completed", is_flag=True)
|
|
101
|
+
def search(list_name: str | None, query: str, field: str, include_completed: bool, json_output: bool) -> None:
|
|
102
|
+
"""Server-side text search over tasks (CalDAV text-match)."""
|
|
103
|
+
cfg = load()
|
|
104
|
+
fields = _TASK_SEARCH_FIELDS[field]
|
|
105
|
+
seen: set[str] = set()
|
|
106
|
+
out: list[dict] = []
|
|
107
|
+
with spinner(f"Searching '{query}' in tasks", json_output):
|
|
108
|
+
with caldav_principal(cfg) as principal:
|
|
109
|
+
cal = _find_task_list(principal, list_name)
|
|
110
|
+
for f in fields:
|
|
111
|
+
kwargs = {f: query, "todo": True, "include_completed": include_completed}
|
|
112
|
+
for todo in cal.search(**kwargs):
|
|
113
|
+
ical = ICal.from_ical(todo.data)
|
|
114
|
+
for component in ical.walk("VTODO"):
|
|
115
|
+
uid = str(component.get("UID"))
|
|
116
|
+
if uid in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(uid)
|
|
119
|
+
out.append(_vtodo_to_dict(component, cal.name))
|
|
120
|
+
render_tasks(out, json_output)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@verbose_option
|
|
124
|
+
@json_option
|
|
125
|
+
@tasks.command()
|
|
126
|
+
@click.option("--list", "list_name", default=None)
|
|
127
|
+
@click.option("--summary", required=True)
|
|
128
|
+
@click.option("--due", default=None, help="ISO 8601 due date.")
|
|
129
|
+
@click.option("--priority", default=None, type=int, help="0 (none) - 9 (lowest); 1 highest.")
|
|
130
|
+
@click.option("--description", default="")
|
|
131
|
+
def create(
|
|
132
|
+
list_name: str | None,
|
|
133
|
+
summary: str,
|
|
134
|
+
due: str | None,
|
|
135
|
+
priority: int | None,
|
|
136
|
+
description: str,
|
|
137
|
+
json_output: bool,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Create a new task."""
|
|
140
|
+
cfg = load()
|
|
141
|
+
ical = ICal()
|
|
142
|
+
ical.add("prodid", "-//nextcloud-cli//EN")
|
|
143
|
+
ical.add("version", "2.0")
|
|
144
|
+
todo = Todo()
|
|
145
|
+
uid = str(uuid.uuid4())
|
|
146
|
+
todo.add("uid", uid)
|
|
147
|
+
todo.add("summary", summary)
|
|
148
|
+
todo.add("status", "NEEDS-ACTION")
|
|
149
|
+
if due:
|
|
150
|
+
todo.add("due", parse_datetime(due, cfg.timezone))
|
|
151
|
+
if priority is not None:
|
|
152
|
+
todo.add("priority", priority)
|
|
153
|
+
if description:
|
|
154
|
+
todo.add("description", description)
|
|
155
|
+
ical.add_component(todo)
|
|
156
|
+
|
|
157
|
+
with spinner(f"Creating task '{summary}'", json_output):
|
|
158
|
+
with caldav_principal(cfg) as principal:
|
|
159
|
+
cal = _find_task_list(principal, list_name)
|
|
160
|
+
cal.save_todo(ical.to_ical().decode())
|
|
161
|
+
render_status("task created", json_output, uid=uid, summary=summary)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@verbose_option
|
|
165
|
+
@json_option
|
|
166
|
+
@tasks.command()
|
|
167
|
+
@click.option("--list", "list_name", default=None)
|
|
168
|
+
@click.option("--uid", required=True)
|
|
169
|
+
def complete(list_name: str | None, uid: str, json_output: bool) -> None:
|
|
170
|
+
"""Mark a task as completed."""
|
|
171
|
+
cfg = load()
|
|
172
|
+
with spinner(f"Completing task {uid}", json_output):
|
|
173
|
+
with caldav_principal(cfg) as principal:
|
|
174
|
+
cal = _find_task_list(principal, list_name)
|
|
175
|
+
try:
|
|
176
|
+
todo = cal.todo_by_uid(uid)
|
|
177
|
+
except Exception:
|
|
178
|
+
fail(f"task not found: {uid}")
|
|
179
|
+
ical = ICal.from_ical(todo.data)
|
|
180
|
+
for component in ical.walk("VTODO"):
|
|
181
|
+
component["STATUS"] = "COMPLETED"
|
|
182
|
+
component["COMPLETED"] = datetime.utcnow()
|
|
183
|
+
component["PERCENT-COMPLETE"] = 100
|
|
184
|
+
todo.data = ical.to_ical().decode()
|
|
185
|
+
todo.save()
|
|
186
|
+
render_status("task completed", json_output, uid=uid)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@verbose_option
|
|
190
|
+
@json_option
|
|
191
|
+
@tasks.command()
|
|
192
|
+
@click.option("--list", "list_name", default=None)
|
|
193
|
+
@click.option("--uid", required=True)
|
|
194
|
+
@click.option("--summary", default=None)
|
|
195
|
+
@click.option("--due", default=None)
|
|
196
|
+
@click.option("--priority", default=None, type=int)
|
|
197
|
+
@click.option("--description", default=None)
|
|
198
|
+
def edit(
|
|
199
|
+
list_name: str | None,
|
|
200
|
+
uid: str,
|
|
201
|
+
summary: str | None,
|
|
202
|
+
due: str | None,
|
|
203
|
+
priority: int | None,
|
|
204
|
+
description: str | None,
|
|
205
|
+
json_output: bool,
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Update fields of an existing task."""
|
|
208
|
+
cfg = load()
|
|
209
|
+
with spinner(f"Updating task {uid}", json_output):
|
|
210
|
+
with caldav_principal(cfg) as principal:
|
|
211
|
+
cal = _find_task_list(principal, list_name)
|
|
212
|
+
try:
|
|
213
|
+
todo = cal.todo_by_uid(uid)
|
|
214
|
+
except Exception:
|
|
215
|
+
fail(f"task not found: {uid}")
|
|
216
|
+
ical = ICal.from_ical(todo.data)
|
|
217
|
+
for component in ical.walk("VTODO"):
|
|
218
|
+
if summary is not None:
|
|
219
|
+
component["SUMMARY"] = summary
|
|
220
|
+
if due is not None:
|
|
221
|
+
if "DUE" in component:
|
|
222
|
+
component["DUE"].dt = parse_datetime(due, cfg.timezone)
|
|
223
|
+
else:
|
|
224
|
+
component.add("due", parse_datetime(due, cfg.timezone))
|
|
225
|
+
if priority is not None:
|
|
226
|
+
component["PRIORITY"] = priority
|
|
227
|
+
if description is not None:
|
|
228
|
+
component["DESCRIPTION"] = description
|
|
229
|
+
todo.data = ical.to_ical().decode()
|
|
230
|
+
todo.save()
|
|
231
|
+
render_status("task updated", json_output, uid=uid)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@verbose_option
|
|
235
|
+
@json_option
|
|
236
|
+
@tasks.command()
|
|
237
|
+
@click.option("--list", "list_name", default=None)
|
|
238
|
+
@click.option("--uid", required=True)
|
|
239
|
+
def delete(list_name: str | None, uid: str, json_output: bool) -> None:
|
|
240
|
+
"""Delete a task."""
|
|
241
|
+
cfg = load()
|
|
242
|
+
with spinner(f"Deleting task {uid}", json_output):
|
|
243
|
+
with caldav_principal(cfg) as principal:
|
|
244
|
+
cal = _find_task_list(principal, list_name)
|
|
245
|
+
try:
|
|
246
|
+
todo = cal.todo_by_uid(uid)
|
|
247
|
+
except Exception:
|
|
248
|
+
fail(f"task not found: {uid}")
|
|
249
|
+
todo.delete()
|
|
250
|
+
render_status("deleted", json_output, uid=uid)
|
nextcloud_cli/config.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Credential and configuration storage.
|
|
2
|
+
|
|
3
|
+
Credentials (app password) are stored in the OS keyring when available, with a
|
|
4
|
+
chmod 0600 JSON file fallback for systems without a keyring backend.
|
|
5
|
+
|
|
6
|
+
Non-secret config (URL, username, timezone) lives in
|
|
7
|
+
``~/.config/nextcloud-cli/config.json``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
import keyring
|
|
20
|
+
from keyring.errors import KeyringError
|
|
21
|
+
|
|
22
|
+
KEYRING_SERVICE = "nextcloud-cli"
|
|
23
|
+
CONFIG_DIR = Path(os.environ.get("NEXTCLOUD_CLI_HOME", Path.home() / ".config" / "nextcloud-cli"))
|
|
24
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
25
|
+
SECRETS_FALLBACK_FILE = CONFIG_DIR / "secrets.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
url: str
|
|
31
|
+
username: str
|
|
32
|
+
password: str
|
|
33
|
+
timezone: str = "UTC"
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def webdav_files_url(self) -> str:
|
|
37
|
+
return f"{self.url.rstrip('/')}/remote.php/dav/files/{self.username}"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def caldav_url(self) -> str:
|
|
41
|
+
return f"{self.url.rstrip('/')}/remote.php/dav"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def carddav_principal(self) -> str:
|
|
45
|
+
return f"{self.url.rstrip('/')}/remote.php/dav/addressbooks/users/{self.username}/"
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def notes_api_url(self) -> str:
|
|
49
|
+
return f"{self.url.rstrip('/')}/index.php/apps/notes/api/v1/notes"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ensure_dir() -> None:
|
|
53
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
try:
|
|
55
|
+
os.chmod(CONFIG_DIR, stat.S_IRWXU)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _write_secure(path: Path, data: dict) -> None:
|
|
61
|
+
_ensure_dir()
|
|
62
|
+
tmp = path.with_suffix(".tmp")
|
|
63
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
64
|
+
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
|
|
65
|
+
tmp.replace(path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _read_json(path: Path) -> dict:
|
|
69
|
+
if not path.exists():
|
|
70
|
+
return {}
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(path.read_text())
|
|
73
|
+
except (OSError, json.JSONDecodeError):
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save(url: str, username: str, password: str, timezone: str = "UTC") -> None:
|
|
78
|
+
"""Persist credentials and configuration."""
|
|
79
|
+
_ensure_dir()
|
|
80
|
+
_write_secure(CONFIG_FILE, {"url": url, "username": username, "timezone": timezone})
|
|
81
|
+
|
|
82
|
+
account = f"{username}@{url}"
|
|
83
|
+
try:
|
|
84
|
+
keyring.set_password(KEYRING_SERVICE, account, password)
|
|
85
|
+
if SECRETS_FALLBACK_FILE.exists():
|
|
86
|
+
SECRETS_FALLBACK_FILE.unlink()
|
|
87
|
+
except KeyringError:
|
|
88
|
+
_write_secure(SECRETS_FALLBACK_FILE, {account: password})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_password(username: str, url: str) -> str | None:
|
|
92
|
+
account = f"{username}@{url}"
|
|
93
|
+
try:
|
|
94
|
+
pwd = keyring.get_password(KEYRING_SERVICE, account)
|
|
95
|
+
if pwd:
|
|
96
|
+
return pwd
|
|
97
|
+
except KeyringError:
|
|
98
|
+
pass
|
|
99
|
+
fallback = _read_json(SECRETS_FALLBACK_FILE)
|
|
100
|
+
return fallback.get(account)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load() -> Config:
|
|
104
|
+
"""Load full configuration. Falls back to environment variables when
|
|
105
|
+
no on-disk config is present, matching the original hermes-nextcloud
|
|
106
|
+
contract (``NEXTCLOUD_URL`` / ``NEXTCLOUD_USER`` / ``NEXTCLOUD_TOKEN``).
|
|
107
|
+
"""
|
|
108
|
+
cfg = _read_json(CONFIG_FILE)
|
|
109
|
+
url = cfg.get("url") or os.environ.get("NEXTCLOUD_URL")
|
|
110
|
+
username = cfg.get("username") or os.environ.get("NEXTCLOUD_USER")
|
|
111
|
+
timezone = cfg.get("timezone") or os.environ.get("NEXTCLOUD_TIMEZONE", "UTC")
|
|
112
|
+
|
|
113
|
+
if not url or not username:
|
|
114
|
+
raise ConfigError(
|
|
115
|
+
"not logged in โ run `nxcloud login` "
|
|
116
|
+
"or set NEXTCLOUD_URL / NEXTCLOUD_USER / NEXTCLOUD_TOKEN"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
password = os.environ.get("NEXTCLOUD_TOKEN") or _load_password(username, url)
|
|
120
|
+
if not password:
|
|
121
|
+
raise ConfigError(
|
|
122
|
+
"no app password available โ re-run `nxcloud login` or set NEXTCLOUD_TOKEN"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return Config(url=url, username=username, password=password, timezone=timezone)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def clear() -> None:
|
|
129
|
+
"""Remove stored credentials and configuration."""
|
|
130
|
+
cfg = _read_json(CONFIG_FILE)
|
|
131
|
+
username = cfg.get("username")
|
|
132
|
+
url = cfg.get("url")
|
|
133
|
+
if username and url:
|
|
134
|
+
try:
|
|
135
|
+
keyring.delete_password(KEYRING_SERVICE, f"{username}@{url}")
|
|
136
|
+
except KeyringError:
|
|
137
|
+
pass
|
|
138
|
+
for path in (CONFIG_FILE, SECRETS_FALLBACK_FILE):
|
|
139
|
+
if path.exists():
|
|
140
|
+
path.unlink()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ConfigError(click.ClickException):
|
|
144
|
+
"""Raised when configuration is missing or invalid.
|
|
145
|
+
|
|
146
|
+
Inheriting from :class:`click.ClickException` lets the CLI print a clean
|
|
147
|
+
one-line error and exit with status 1 instead of a Python traceback.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, message: str) -> None:
|
|
151
|
+
super().__init__(message)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Rich renderers for human-friendly CLI output.
|
|
2
|
+
|
|
3
|
+
Every renderer accepts ``json_output``: when True, the function delegates to
|
|
4
|
+
``emit`` so the legacy machine-readable format is preserved untouched.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from rich.box import ROUNDED, SIMPLE_HEAD
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from nextcloud_cli.utils import console, emit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _format_epoch(value: Any) -> str:
|
|
21
|
+
"""Render a Unix epoch (int/str/float) as 'YYYY-MM-DD HH:MM' local time."""
|
|
22
|
+
if value in (None, "", 0):
|
|
23
|
+
return ""
|
|
24
|
+
try:
|
|
25
|
+
ts = int(value)
|
|
26
|
+
except (TypeError, ValueError):
|
|
27
|
+
return str(value)
|
|
28
|
+
try:
|
|
29
|
+
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
|
30
|
+
except (OverflowError, OSError, ValueError):
|
|
31
|
+
return str(value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _truncate(value: str, limit: int = 60) -> str:
|
|
35
|
+
if value is None:
|
|
36
|
+
return ""
|
|
37
|
+
value = str(value).replace("\n", " ").strip()
|
|
38
|
+
return value if len(value) <= limit else value[: limit - 1] + "โฆ"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_status(message: str, json_output: bool, **fields: Any) -> None:
|
|
42
|
+
"""Success/result line. JSON mode emits a {status, ...} object."""
|
|
43
|
+
if json_output:
|
|
44
|
+
emit({"status": message, **fields})
|
|
45
|
+
return
|
|
46
|
+
body = Text(message, style="bold green")
|
|
47
|
+
if fields:
|
|
48
|
+
body.append("\n")
|
|
49
|
+
for k, v in fields.items():
|
|
50
|
+
body.append(f"\n {k}: ", style="dim")
|
|
51
|
+
body.append(str(v), style="white")
|
|
52
|
+
console.print(Panel(body, border_style="green", box=ROUNDED, expand=False))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def render_files_list(items: list[dict], path: str, json_output: bool) -> None:
|
|
56
|
+
if json_output:
|
|
57
|
+
emit(items)
|
|
58
|
+
return
|
|
59
|
+
table = Table(
|
|
60
|
+
title=f"[bold]{path}[/bold]",
|
|
61
|
+
box=SIMPLE_HEAD,
|
|
62
|
+
header_style="bold magenta",
|
|
63
|
+
title_justify="left",
|
|
64
|
+
)
|
|
65
|
+
table.add_column("", width=2)
|
|
66
|
+
table.add_column("Name", style="cyan", no_wrap=True)
|
|
67
|
+
table.add_column("Size", justify="right", style="green")
|
|
68
|
+
table.add_column("Modified", style="dim")
|
|
69
|
+
for entry in items:
|
|
70
|
+
icon = "๐" if entry.get("type") == "directory" else "๐"
|
|
71
|
+
size = "โ" if entry.get("type") == "directory" else entry.get("size_human") or "0 B"
|
|
72
|
+
table.add_row(icon, entry["name"], size, entry.get("modified") or "")
|
|
73
|
+
console.print(table)
|
|
74
|
+
console.print(f"[dim]{len(items)} item(s)[/dim]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_notes_list(items: list[dict], json_output: bool) -> None:
|
|
78
|
+
if json_output:
|
|
79
|
+
emit(items)
|
|
80
|
+
return
|
|
81
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="๐ Notes", title_justify="left")
|
|
82
|
+
table.add_column("ID", justify="right", style="dim")
|
|
83
|
+
table.add_column("Title", style="cyan")
|
|
84
|
+
table.add_column("Category", style="yellow")
|
|
85
|
+
table.add_column("Modified", style="dim")
|
|
86
|
+
for n in items:
|
|
87
|
+
table.add_row(
|
|
88
|
+
str(n.get("id", "")),
|
|
89
|
+
_truncate(n.get("title") or "(untitled)", 50),
|
|
90
|
+
n.get("category") or "โ",
|
|
91
|
+
_format_epoch(n.get("modified")),
|
|
92
|
+
)
|
|
93
|
+
console.print(table)
|
|
94
|
+
console.print(f"[dim]{len(items)} note(s)[/dim]")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_note(note: dict, json_output: bool) -> None:
|
|
98
|
+
if json_output:
|
|
99
|
+
emit(note)
|
|
100
|
+
return
|
|
101
|
+
title = note.get("title") or "(untitled)"
|
|
102
|
+
category = note.get("category") or ""
|
|
103
|
+
header = f"[bold cyan]{title}[/bold cyan]"
|
|
104
|
+
if category:
|
|
105
|
+
header += f" [yellow]ยท[/yellow] [yellow]{category}[/yellow]"
|
|
106
|
+
body = note.get("content", "") or "[dim](empty)[/dim]"
|
|
107
|
+
console.print(Panel(body, title=header, border_style="cyan", box=ROUNDED))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_calendars(items: list[dict], json_output: bool) -> None:
|
|
111
|
+
if json_output:
|
|
112
|
+
emit(items)
|
|
113
|
+
return
|
|
114
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="๐
Calendars", title_justify="left")
|
|
115
|
+
table.add_column("Name", style="cyan")
|
|
116
|
+
table.add_column("URL", style="dim")
|
|
117
|
+
for c in items:
|
|
118
|
+
table.add_row(c["name"], c.get("url", ""))
|
|
119
|
+
console.print(table)
|
|
120
|
+
console.print(f"[dim]{len(items)} calendar(s)[/dim]")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def render_events(items: list[dict], json_output: bool) -> None:
|
|
124
|
+
if json_output:
|
|
125
|
+
emit(items)
|
|
126
|
+
return
|
|
127
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="๐ Events", title_justify="left")
|
|
128
|
+
table.add_column("Start", style="green")
|
|
129
|
+
table.add_column("End", style="dim")
|
|
130
|
+
table.add_column("Summary", style="cyan")
|
|
131
|
+
table.add_column("Location", style="yellow")
|
|
132
|
+
table.add_column("Attendees", justify="right", style="magenta")
|
|
133
|
+
for e in items:
|
|
134
|
+
table.add_row(
|
|
135
|
+
e.get("start") or "",
|
|
136
|
+
e.get("end") or "",
|
|
137
|
+
_truncate(e.get("summary") or "", 50),
|
|
138
|
+
_truncate(e.get("location") or "", 25),
|
|
139
|
+
str(len(e.get("attendees") or [])),
|
|
140
|
+
)
|
|
141
|
+
console.print(table)
|
|
142
|
+
console.print(f"[dim]{len(items)} event(s)[/dim]")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
_TASK_STATUS_STYLE = {
|
|
146
|
+
"COMPLETED": ("โ", "green"),
|
|
147
|
+
"NEEDS-ACTION": ("โ", "yellow"),
|
|
148
|
+
"IN-PROCESS": ("โ", "cyan"),
|
|
149
|
+
"CANCELLED": ("โ", "red"),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def render_tasks(items: list[dict], json_output: bool) -> None:
|
|
154
|
+
if json_output:
|
|
155
|
+
emit(items)
|
|
156
|
+
return
|
|
157
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="โ
Tasks", title_justify="left")
|
|
158
|
+
table.add_column("", width=1)
|
|
159
|
+
table.add_column("Summary", style="cyan")
|
|
160
|
+
table.add_column("Due", style="green")
|
|
161
|
+
table.add_column("Priority", justify="right")
|
|
162
|
+
table.add_column("List", style="dim")
|
|
163
|
+
for t in items:
|
|
164
|
+
status = (t.get("status") or "").upper()
|
|
165
|
+
glyph, style = _TASK_STATUS_STYLE.get(status, ("ยท", "white"))
|
|
166
|
+
prio = t.get("priority")
|
|
167
|
+
prio_str = str(prio) if prio else "โ"
|
|
168
|
+
summary = _truncate(t.get("summary") or "", 50)
|
|
169
|
+
if status == "COMPLETED":
|
|
170
|
+
summary = f"[strike dim]{summary}[/strike dim]"
|
|
171
|
+
table.add_row(f"[{style}]{glyph}[/{style}]", summary, t.get("due") or "โ", prio_str, t.get("list") or "")
|
|
172
|
+
console.print(table)
|
|
173
|
+
console.print(f"[dim]{len(items)} task(s)[/dim]")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def render_addressbooks(items: list[dict], json_output: bool) -> None:
|
|
177
|
+
if json_output:
|
|
178
|
+
emit(items)
|
|
179
|
+
return
|
|
180
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="๐ Address books", title_justify="left")
|
|
181
|
+
table.add_column("Name", style="cyan")
|
|
182
|
+
table.add_column("Display name", style="white")
|
|
183
|
+
table.add_column("Href", style="dim")
|
|
184
|
+
for b in items:
|
|
185
|
+
table.add_row(b["name"], b.get("displayname", ""), b.get("href", ""))
|
|
186
|
+
console.print(table)
|
|
187
|
+
console.print(f"[dim]{len(items)} address book(s)[/dim]")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def render_contacts(items: list[dict], json_output: bool) -> None:
|
|
191
|
+
if json_output:
|
|
192
|
+
emit(items)
|
|
193
|
+
return
|
|
194
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta", title="๐ฅ Contacts", title_justify="left")
|
|
195
|
+
table.add_column("Name", style="cyan")
|
|
196
|
+
table.add_column("Emails", style="green")
|
|
197
|
+
table.add_column("Phones", style="yellow")
|
|
198
|
+
table.add_column("UID", style="dim")
|
|
199
|
+
for c in items:
|
|
200
|
+
emails = ", ".join(c.get("emails") or []) or "โ"
|
|
201
|
+
phones = ", ".join(c.get("phones") or []) or "โ"
|
|
202
|
+
table.add_row(c.get("fn") or "(no name)", _truncate(emails, 40), _truncate(phones, 30), _truncate(c.get("uid") or "", 20))
|
|
203
|
+
console.print(table)
|
|
204
|
+
console.print(f"[dim]{len(items)} contact(s)[/dim]")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_contact(card: dict, json_output: bool) -> None:
|
|
208
|
+
if json_output:
|
|
209
|
+
emit(card)
|
|
210
|
+
return
|
|
211
|
+
body = Text()
|
|
212
|
+
body.append(card.get("fn") or "(no name)", style="bold cyan")
|
|
213
|
+
body.append("\n\n")
|
|
214
|
+
body.append("Emails:\n", style="bold")
|
|
215
|
+
for e in card.get("emails") or []:
|
|
216
|
+
body.append(f" โข {e}\n", style="green")
|
|
217
|
+
if not card.get("emails"):
|
|
218
|
+
body.append(" โ\n", style="dim")
|
|
219
|
+
body.append("\nPhones:\n", style="bold")
|
|
220
|
+
for p in card.get("phones") or []:
|
|
221
|
+
body.append(f" โข {p}\n", style="yellow")
|
|
222
|
+
if not card.get("phones"):
|
|
223
|
+
body.append(" โ\n", style="dim")
|
|
224
|
+
body.append("\nUID: ", style="dim")
|
|
225
|
+
body.append(card.get("uid") or "โ", style="dim")
|
|
226
|
+
console.print(Panel(body, border_style="cyan", box=ROUNDED, expand=False))
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def render_check(results: dict, json_output: bool) -> None:
|
|
230
|
+
if json_output:
|
|
231
|
+
emit(results)
|
|
232
|
+
return
|
|
233
|
+
header = Text()
|
|
234
|
+
header.append("URL: ", style="bold")
|
|
235
|
+
header.append(f"{results['url']}\n")
|
|
236
|
+
header.append("User: ", style="bold")
|
|
237
|
+
header.append(f"{results['username']}\n")
|
|
238
|
+
header.append("Timezone: ", style="bold")
|
|
239
|
+
header.append(f"{results['timezone']}")
|
|
240
|
+
console.print(Panel(header, title="๐ Connectivity check", border_style="cyan", box=ROUNDED, expand=False))
|
|
241
|
+
|
|
242
|
+
table = Table(box=SIMPLE_HEAD, header_style="bold magenta")
|
|
243
|
+
table.add_column("Endpoint", style="cyan")
|
|
244
|
+
table.add_column("Method", style="dim")
|
|
245
|
+
table.add_column("Status", justify="right")
|
|
246
|
+
table.add_column("Time", justify="right", style="green")
|
|
247
|
+
table.add_column("URL", style="dim")
|
|
248
|
+
all_ok = True
|
|
249
|
+
for label, info in results["endpoints"].items():
|
|
250
|
+
ok = info.get("ok")
|
|
251
|
+
if not ok:
|
|
252
|
+
all_ok = False
|
|
253
|
+
if ok:
|
|
254
|
+
status_cell = f"[green]โ {info.get('status')}[/green]"
|
|
255
|
+
else:
|
|
256
|
+
status_cell = f"[red]โ {info.get('status') or info.get('error_type', 'ERR')}[/red]"
|
|
257
|
+
table.add_row(label, info["method"], status_cell, f"{info['elapsed_ms']} ms", info["url"])
|
|
258
|
+
console.print(table)
|
|
259
|
+
if all_ok:
|
|
260
|
+
console.print("[bold green]โ all endpoints reachable[/bold green]")
|
|
261
|
+
else:
|
|
262
|
+
console.print("[bold red]โ some endpoints failed[/bold red]")
|