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
nextcloud_cli/VERSION.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.1.0
|
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()
|
nextcloud_cli/client.py
ADDED
|
@@ -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)
|