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.
@@ -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)
@@ -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]")