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 @@
1
+ 0.1.0
@@ -0,0 +1,3 @@
1
+ """Modern command-line client for Nextcloud."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from nextcloud_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
nextcloud_cli/cli.py ADDED
@@ -0,0 +1,36 @@
1
+ """Top-level Click entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from nextcloud_cli import __version__
8
+ from nextcloud_cli.commands.calendar import calendar
9
+ from nextcloud_cli.commands.check import check
10
+ from nextcloud_cli.commands.contacts import contacts
11
+ from nextcloud_cli.commands.files import files
12
+ from nextcloud_cli.commands.notes import notes
13
+ from nextcloud_cli.commands.setup import login, logout
14
+ from nextcloud_cli.commands.tasks import tasks
15
+ from nextcloud_cli.utils import CONTEXT_SETTINGS, verbose_option
16
+
17
+
18
+ @click.group(context_settings=CONTEXT_SETTINGS)
19
+ @click.version_option(__version__, prog_name="nxcloud")
20
+ @verbose_option
21
+ def main() -> None:
22
+ """nxcloud — modern command-line client for Nextcloud."""
23
+
24
+
25
+ main.add_command(login)
26
+ main.add_command(logout)
27
+ main.add_command(check)
28
+ main.add_command(files)
29
+ main.add_command(notes)
30
+ main.add_command(calendar)
31
+ main.add_command(tasks)
32
+ main.add_command(contacts)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -0,0 +1,44 @@
1
+ """Factory for the various protocol clients used by the CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterator
6
+ from contextlib import contextmanager
7
+
8
+ import caldav
9
+ import httpx
10
+ from webdav4.client import Client as WebDAVClient
11
+
12
+ from nextcloud_cli.config import Config
13
+
14
+ DEFAULT_TIMEOUT = 30.0
15
+
16
+
17
+ def http_client(cfg: Config, *, accept: str = "application/json") -> httpx.Client:
18
+ """HTTP client preconfigured with Basic auth and sensible defaults."""
19
+ return httpx.Client(
20
+ auth=(cfg.username, cfg.password),
21
+ headers={"Accept": accept, "OCS-APIRequest": "true"},
22
+ timeout=DEFAULT_TIMEOUT,
23
+ follow_redirects=True,
24
+ )
25
+
26
+
27
+ def webdav_client(cfg: Config) -> WebDAVClient:
28
+ """WebDAV client scoped to the user's files endpoint."""
29
+ return WebDAVClient(
30
+ cfg.webdav_files_url,
31
+ auth=(cfg.username, cfg.password),
32
+ timeout=DEFAULT_TIMEOUT,
33
+ )
34
+
35
+
36
+ @contextmanager
37
+ def caldav_principal(cfg: Config) -> Iterator[caldav.Principal]:
38
+ """Context manager yielding the user's CalDAV principal."""
39
+ with caldav.DAVClient(
40
+ url=cfg.caldav_url,
41
+ username=cfg.username,
42
+ password=cfg.password,
43
+ ) as client:
44
+ yield client.principal()
File without changes
@@ -0,0 +1,442 @@
1
+ """Calendar (CalDAV) operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import uuid
7
+ from datetime import datetime, timedelta
8
+ from zoneinfo import ZoneInfo
9
+
10
+ import click
11
+ from icalendar import Calendar as ICal
12
+ from icalendar import Event as IEvent
13
+ from icalendar import vCalAddress, vText
14
+
15
+ from nextcloud_cli.client import caldav_principal
16
+ from nextcloud_cli.config import load
17
+ from nextcloud_cli.rendering import render_calendars, render_events, render_status
18
+ from nextcloud_cli.utils import (
19
+ CONTEXT_SETTINGS,
20
+ fail,
21
+ json_option,
22
+ parse_datetime,
23
+ spinner,
24
+ verbose_option,
25
+ )
26
+
27
+
28
+ @click.group(context_settings=CONTEXT_SETTINGS)
29
+ def calendar() -> None:
30
+ """Manage calendars and events via CalDAV."""
31
+
32
+
33
+ def _find_calendar(principal, name: str):
34
+ for cal in principal.calendars():
35
+ if cal.name == name:
36
+ return cal
37
+ fail(f"calendar not found: {name}")
38
+
39
+
40
+ def _attendee_property(spec: str) -> vCalAddress:
41
+ """Build an ATTENDEE property from a ``Name <email>`` or ``email`` string."""
42
+ name = None
43
+ email = spec.strip()
44
+ if "<" in spec and ">" in spec:
45
+ name = spec.split("<", 1)[0].strip().strip('"')
46
+ email = spec.split("<", 1)[1].split(">", 1)[0].strip()
47
+
48
+ addr = vCalAddress(f"mailto:{email}")
49
+ if name:
50
+ addr.params["CN"] = vText(name)
51
+ addr.params["ROLE"] = vText("REQ-PARTICIPANT")
52
+ addr.params["PARTSTAT"] = vText("NEEDS-ACTION")
53
+ addr.params["RSVP"] = vText("TRUE")
54
+ return addr
55
+
56
+
57
+ @verbose_option
58
+ @json_option
59
+ @calendar.command("list")
60
+ def list_calendars(json_output: bool) -> None:
61
+ """List all calendars."""
62
+ cfg = load()
63
+ with spinner("Fetching calendars", json_output):
64
+ with caldav_principal(cfg) as principal:
65
+ cals = [
66
+ {
67
+ "name": cal.name,
68
+ "url": str(cal.url),
69
+ }
70
+ for cal in principal.calendars()
71
+ ]
72
+ render_calendars(cals, json_output)
73
+
74
+
75
+ def _shortcut_range(
76
+ today: bool,
77
+ this_week: bool,
78
+ next_week: bool,
79
+ this_month: bool,
80
+ next_month: bool,
81
+ nxt: str | None,
82
+ tz: str,
83
+ ) -> tuple[datetime, datetime] | None:
84
+ """Resolve a date-range shortcut to (start, end) in the configured timezone.
85
+
86
+ Returns None if no shortcut was selected. Raises via ``fail`` on conflicts
87
+ or malformed values.
88
+ """
89
+ chosen = [
90
+ name
91
+ for name, on in (
92
+ ("--today", today),
93
+ ("--this-week", this_week),
94
+ ("--next-week", next_week),
95
+ ("--this-month", this_month),
96
+ ("--next-month", next_month),
97
+ ("--next", bool(nxt)),
98
+ )
99
+ if on
100
+ ]
101
+ if len(chosen) > 1:
102
+ fail(f"shortcuts are mutually exclusive: {', '.join(chosen)}")
103
+ if not chosen:
104
+ return None
105
+
106
+ try:
107
+ zone = ZoneInfo(tz)
108
+ except Exception:
109
+ zone = ZoneInfo("UTC")
110
+ now = datetime.now(zone)
111
+ midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
112
+
113
+ def _first_of_next_month(d: datetime) -> datetime:
114
+ return (d.replace(day=28) + timedelta(days=4)).replace(day=1)
115
+
116
+ if today:
117
+ return midnight, midnight + timedelta(days=1)
118
+ if this_week:
119
+ monday = midnight - timedelta(days=midnight.weekday())
120
+ return monday, monday + timedelta(days=7)
121
+ if next_week:
122
+ next_monday = midnight - timedelta(days=midnight.weekday()) + timedelta(days=7)
123
+ return next_monday, next_monday + timedelta(days=7)
124
+ if this_month:
125
+ first = midnight.replace(day=1)
126
+ return first, _first_of_next_month(first)
127
+ if next_month:
128
+ first_next = _first_of_next_month(midnight.replace(day=1))
129
+ return first_next, _first_of_next_month(first_next)
130
+ # --next Xd / Xh / Xw
131
+ match = re.fullmatch(r"\s*(\d+)\s*([dhw])\s*", nxt or "", re.IGNORECASE)
132
+ if not match:
133
+ fail("--next expects a value like '7d', '48h' or '2w'")
134
+ n, unit = int(match.group(1)), match.group(2).lower()
135
+ delta = {"d": timedelta(days=n), "h": timedelta(hours=n), "w": timedelta(weeks=n)}[unit]
136
+ return now, now + delta
137
+
138
+
139
+ @verbose_option
140
+ @json_option
141
+ @calendar.command()
142
+ @click.option("--calendar", "calendar_name", required=True)
143
+ @click.option("--start", default=None, help="ISO 8601 start of range.")
144
+ @click.option("--end", default=None, help="ISO 8601 end of range.")
145
+ @click.option("--today", is_flag=True, help="Events from today (00:00 → 24:00, local TZ).")
146
+ @click.option("--this-week", is_flag=True, help="Events from this week (Monday → Monday).")
147
+ @click.option("--next-week", is_flag=True, help="Events from next week (upcoming Monday → Monday after).")
148
+ @click.option("--this-month", is_flag=True, help="Events from the current calendar month.")
149
+ @click.option("--next-month", is_flag=True, help="Events from the next calendar month.")
150
+ @click.option("--next", "next_", default=None, metavar="Xd|Xh|Xw", help="Events from now to now + duration (e.g. 7d, 48h, 2w).")
151
+ def events(
152
+ calendar_name: str,
153
+ start: str | None,
154
+ end: str | None,
155
+ today: bool,
156
+ this_week: bool,
157
+ next_week: bool,
158
+ this_month: bool,
159
+ next_month: bool,
160
+ next_: str | None,
161
+ json_output: bool,
162
+ ) -> None:
163
+ """List events in a calendar, optionally within a date range."""
164
+ cfg = load()
165
+ shortcut = _shortcut_range(today, this_week, next_week, this_month, next_month, next_, cfg.timezone)
166
+ if shortcut and (start or end):
167
+ fail("--start/--end cannot be combined with shortcut flags (--today, --this-week, --next-week, --this-month, --next-month, --next)")
168
+
169
+ out: list[dict] = []
170
+ with spinner(f"Fetching events from {calendar_name}", json_output):
171
+ with caldav_principal(cfg) as principal:
172
+ cal = _find_calendar(principal, calendar_name)
173
+ if shortcut:
174
+ start_dt, end_dt = shortcut
175
+ results = cal.search(start=start_dt, end=end_dt, event=True, expand=True)
176
+ elif start or end:
177
+ start_dt = parse_datetime(start, cfg.timezone) if start else None
178
+ end_dt = parse_datetime(end, cfg.timezone) if end else None
179
+ results = cal.search(start=start_dt, end=end_dt, event=True, expand=True)
180
+ else:
181
+ results = cal.events()
182
+
183
+ for event in results:
184
+ ical = ICal.from_ical(event.data)
185
+ for component in ical.walk("VEVENT"):
186
+ out.append(_vevent_to_dict(component))
187
+ render_events(out, json_output)
188
+
189
+
190
+ def _vevent_to_dict(component) -> dict:
191
+ attendees = []
192
+ raw = component.get("ATTENDEE")
193
+ raw_list = raw if isinstance(raw, list) else [raw] if raw else []
194
+ for prop in raw_list:
195
+ if prop is None:
196
+ continue
197
+ email = str(prop).replace("mailto:", "", 1)
198
+ attendees.append(
199
+ {
200
+ "email": email,
201
+ "cn": str(prop.params.get("CN", "")),
202
+ "partstat": str(prop.params.get("PARTSTAT", "")),
203
+ }
204
+ )
205
+ return {
206
+ "uid": str(component.get("UID")),
207
+ "summary": str(component.get("SUMMARY", "")),
208
+ "start": component.decoded("DTSTART").isoformat() if component.get("DTSTART") else None,
209
+ "end": component.decoded("DTEND").isoformat() if component.get("DTEND") else None,
210
+ "location": str(component.get("LOCATION", "")),
211
+ "description": str(component.get("DESCRIPTION", "")),
212
+ "organizer": str(component.get("ORGANIZER", "")).replace("mailto:", "", 1) or None,
213
+ "attendees": attendees,
214
+ }
215
+
216
+
217
+ _EVENT_SEARCH_FIELDS = {
218
+ "summary": ("summary",),
219
+ "description": ("description",),
220
+ "location": ("location",),
221
+ "category": ("category",),
222
+ "all": ("summary", "description", "location"),
223
+ }
224
+
225
+
226
+ @verbose_option
227
+ @json_option
228
+ @calendar.command()
229
+ @click.option("--calendar", "calendar_name", required=True)
230
+ @click.option("--query", required=True, help="Substring to match (case-insensitive, server-side).")
231
+ @click.option(
232
+ "--in",
233
+ "field",
234
+ type=click.Choice(list(_EVENT_SEARCH_FIELDS.keys())),
235
+ default="summary",
236
+ help="Which iCalendar property to search (default: summary).",
237
+ )
238
+ @click.option("--start", default=None, help="ISO 8601 start of range.")
239
+ @click.option("--end", default=None, help="ISO 8601 end of range.")
240
+ @click.option("--today", is_flag=True)
241
+ @click.option("--this-week", is_flag=True)
242
+ @click.option("--next-week", is_flag=True)
243
+ @click.option("--this-month", is_flag=True)
244
+ @click.option("--next-month", is_flag=True)
245
+ @click.option("--next", "next_", default=None, metavar="Xd|Xh|Xw")
246
+ def search(
247
+ calendar_name: str,
248
+ query: str,
249
+ field: str,
250
+ start: str | None,
251
+ end: str | None,
252
+ today: bool,
253
+ this_week: bool,
254
+ next_week: bool,
255
+ this_month: bool,
256
+ next_month: bool,
257
+ next_: str | None,
258
+ json_output: bool,
259
+ ) -> None:
260
+ """Server-side text search over events (CalDAV text-match)."""
261
+ cfg = load()
262
+ shortcut = _shortcut_range(today, this_week, next_week, this_month, next_month, next_, cfg.timezone)
263
+ if shortcut and (start or end):
264
+ fail("--start/--end cannot be combined with shortcut flags")
265
+
266
+ if shortcut:
267
+ start_dt, end_dt = shortcut
268
+ else:
269
+ start_dt = parse_datetime(start, cfg.timezone) if start else None
270
+ end_dt = parse_datetime(end, cfg.timezone) if end else None
271
+
272
+ fields = _EVENT_SEARCH_FIELDS[field]
273
+ seen: set[str] = set()
274
+ out: list[dict] = []
275
+ with spinner(f"Searching '{query}' in {calendar_name}", json_output):
276
+ with caldav_principal(cfg) as principal:
277
+ cal = _find_calendar(principal, calendar_name)
278
+ for f in fields:
279
+ kwargs = {f: query, "event": True}
280
+ if start_dt or end_dt:
281
+ kwargs.update(start=start_dt, end=end_dt, expand=True)
282
+ for ev in cal.search(**kwargs):
283
+ ical = ICal.from_ical(ev.data)
284
+ for component in ical.walk("VEVENT"):
285
+ uid = str(component.get("UID"))
286
+ if uid in seen:
287
+ continue
288
+ seen.add(uid)
289
+ out.append(_vevent_to_dict(component))
290
+ render_events(out, json_output)
291
+
292
+
293
+ @verbose_option
294
+ @json_option
295
+ @calendar.command()
296
+ @click.option("--calendar", "calendar_name", required=True)
297
+ @click.option("--summary", required=True)
298
+ @click.option("--start", required=True, help="ISO 8601 start datetime.")
299
+ @click.option("--end", default=None, help="ISO 8601 end datetime (defaults to start +1h).")
300
+ @click.option("--location", default="")
301
+ @click.option("--description", default="")
302
+ @click.option(
303
+ "--attendee",
304
+ "attendees",
305
+ multiple=True,
306
+ help='Invitee email or "Name <email>". Repeatable.',
307
+ )
308
+ @click.option("--organizer", default=None, help='Organizer email or "Name <email>".')
309
+ def create(
310
+ calendar_name: str,
311
+ summary: str,
312
+ start: str,
313
+ end: str | None,
314
+ location: str,
315
+ description: str,
316
+ attendees: tuple[str, ...],
317
+ organizer: str | None,
318
+ json_output: bool,
319
+ ) -> None:
320
+ """Create a new event."""
321
+ cfg = load()
322
+ start_dt = parse_datetime(start, cfg.timezone)
323
+ end_dt = parse_datetime(end, cfg.timezone) if end else start_dt + timedelta(hours=1)
324
+
325
+ ical = ICal()
326
+ ical.add("prodid", "-//nxcloud//EN")
327
+ ical.add("version", "2.0")
328
+ event = IEvent()
329
+ uid = str(uuid.uuid4())
330
+ event.add("uid", uid)
331
+ event.add("summary", summary)
332
+ event.add("dtstart", start_dt)
333
+ event.add("dtend", end_dt)
334
+ if location:
335
+ event.add("location", location)
336
+ if description:
337
+ event.add("description", description)
338
+
339
+ if organizer:
340
+ org = _attendee_property(organizer)
341
+ event.add("organizer", org)
342
+ for spec in attendees:
343
+ event.add("attendee", _attendee_property(spec), encode=0)
344
+
345
+ ical.add_component(event)
346
+
347
+ with spinner(f"Creating event '{summary}'", json_output):
348
+ with caldav_principal(cfg) as principal:
349
+ cal = _find_calendar(principal, calendar_name)
350
+ saved = cal.save_event(ical.to_ical().decode())
351
+ render_status(
352
+ "event created",
353
+ json_output,
354
+ uid=uid,
355
+ url=str(saved.url),
356
+ attendees=", ".join(str(a).replace("mailto:", "", 1) for a in (event.get("ATTENDEE") or []) if a) or "—",
357
+ )
358
+
359
+
360
+ @verbose_option
361
+ @json_option
362
+ @calendar.command()
363
+ @click.option("--calendar", "calendar_name", required=True)
364
+ @click.option("--uid", required=True)
365
+ @click.option("--summary", default=None)
366
+ @click.option("--start", default=None)
367
+ @click.option("--end", default=None)
368
+ @click.option("--location", default=None)
369
+ @click.option("--description", default=None)
370
+ @click.option("--add-attendee", "add_attendees", multiple=True, help="Add an invitee. Repeatable.")
371
+ @click.option("--remove-attendee", "remove_attendees", multiple=True, help="Remove an invitee by email. Repeatable.")
372
+ def edit(
373
+ calendar_name: str,
374
+ uid: str,
375
+ summary: str | None,
376
+ start: str | None,
377
+ end: str | None,
378
+ location: str | None,
379
+ description: str | None,
380
+ add_attendees: tuple[str, ...],
381
+ remove_attendees: tuple[str, ...],
382
+ json_output: bool,
383
+ ) -> None:
384
+ """Update fields of an existing event."""
385
+ cfg = load()
386
+ with spinner(f"Updating event {uid}", json_output):
387
+ with caldav_principal(cfg) as principal:
388
+ cal = _find_calendar(principal, calendar_name)
389
+ try:
390
+ event = cal.event_by_uid(uid)
391
+ except Exception:
392
+ fail(f"event not found: {uid}")
393
+ ical = ICal.from_ical(event.data)
394
+ for component in ical.walk("VEVENT"):
395
+ if summary is not None:
396
+ component["SUMMARY"] = summary
397
+ if start is not None:
398
+ component["DTSTART"].dt = parse_datetime(start, cfg.timezone)
399
+ if end is not None:
400
+ component["DTEND"].dt = parse_datetime(end, cfg.timezone)
401
+ if location is not None:
402
+ component["LOCATION"] = location
403
+ if description is not None:
404
+ component["DESCRIPTION"] = description
405
+
406
+ if remove_attendees:
407
+ kept = []
408
+ existing = component.get("ATTENDEE")
409
+ if existing is not None:
410
+ existing_list = existing if isinstance(existing, list) else [existing]
411
+ for prop in existing_list:
412
+ email = str(prop).replace("mailto:", "", 1)
413
+ if email not in remove_attendees:
414
+ kept.append(prop)
415
+ del component["ATTENDEE"]
416
+ for prop in kept:
417
+ component.add("attendee", prop, encode=0)
418
+ for spec in add_attendees:
419
+ component.add("attendee", _attendee_property(spec), encode=0)
420
+
421
+ event.data = ical.to_ical().decode()
422
+ event.save()
423
+ render_status("event updated", json_output, uid=uid)
424
+
425
+
426
+ @verbose_option
427
+ @json_option
428
+ @calendar.command()
429
+ @click.option("--calendar", "calendar_name", required=True)
430
+ @click.option("--uid", required=True)
431
+ def delete(calendar_name: str, uid: str, json_output: bool) -> None:
432
+ """Delete an event."""
433
+ cfg = load()
434
+ with spinner(f"Deleting event {uid}", json_output):
435
+ with caldav_principal(cfg) as principal:
436
+ cal = _find_calendar(principal, calendar_name)
437
+ try:
438
+ event = cal.event_by_uid(uid)
439
+ except Exception:
440
+ fail(f"event not found: {uid}")
441
+ event.delete()
442
+ render_status("deleted", json_output, uid=uid)
@@ -0,0 +1,69 @@
1
+ """Connectivity self-check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ import click
8
+ import httpx
9
+
10
+ from nextcloud_cli.client import http_client
11
+ from nextcloud_cli.config import load
12
+ from nextcloud_cli.rendering import render_check
13
+ from nextcloud_cli.utils import CONTEXT_SETTINGS, json_option, spinner, verbose_option
14
+
15
+
16
+ @click.command(context_settings=CONTEXT_SETTINGS)
17
+ @verbose_option
18
+ @json_option
19
+ @click.option("--timeout", default=10.0, type=float, help="Per-endpoint timeout (seconds).")
20
+ def check(timeout: float, json_output: bool) -> None:
21
+ """Verify configuration and reachability of each Nextcloud endpoint."""
22
+ cfg = load()
23
+ results = {
24
+ "url": cfg.url,
25
+ "username": cfg.username,
26
+ "timezone": cfg.timezone,
27
+ "timeout": timeout,
28
+ "endpoints": {},
29
+ }
30
+
31
+ notes_probe_url = f"{cfg.notes_api_url}?exclude=title,content,category,modified,favorite,etag"
32
+
33
+ probes = [
34
+ ("webdav", f"{cfg.webdav_files_url}/", "PROPFIND"),
35
+ ("notes", notes_probe_url, "GET"),
36
+ ("caldav", f"{cfg.caldav_url}/principals/users/{cfg.username}/", "PROPFIND"),
37
+ ("carddav", cfg.carddav_principal, "PROPFIND"),
38
+ ]
39
+
40
+ with spinner("Probing endpoints", json_output):
41
+ with http_client(cfg) as http:
42
+ for label, url, method in probes:
43
+ started = time.monotonic()
44
+ try:
45
+ response = http.request(
46
+ method,
47
+ url,
48
+ headers={"Depth": "0"} if method == "PROPFIND" else {},
49
+ timeout=timeout,
50
+ )
51
+ results["endpoints"][label] = {
52
+ "url": url,
53
+ "method": method,
54
+ "status": response.status_code,
55
+ "ok": response.status_code < 400,
56
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
57
+ }
58
+ except httpx.HTTPError as exc:
59
+ results["endpoints"][label] = {
60
+ "url": url,
61
+ "method": method,
62
+ "status": None,
63
+ "ok": False,
64
+ "elapsed_ms": round((time.monotonic() - started) * 1000),
65
+ "error": str(exc),
66
+ "error_type": type(exc).__name__,
67
+ }
68
+
69
+ render_check(results, json_output)