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,266 @@
|
|
|
1
|
+
"""Contacts (CardDAV) operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from xml.etree import ElementTree as ET
|
|
7
|
+
from xml.sax.saxutils import escape as xml_escape
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import vobject
|
|
11
|
+
|
|
12
|
+
from nextcloud_cli.client import http_client
|
|
13
|
+
from nextcloud_cli.config import Config, load
|
|
14
|
+
from nextcloud_cli.rendering import (
|
|
15
|
+
render_addressbooks,
|
|
16
|
+
render_contact,
|
|
17
|
+
render_contacts,
|
|
18
|
+
render_status,
|
|
19
|
+
)
|
|
20
|
+
from nextcloud_cli.utils import CONTEXT_SETTINGS, fail, json_option, spinner, verbose_option
|
|
21
|
+
|
|
22
|
+
CARDDAV_NS = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"}
|
|
23
|
+
|
|
24
|
+
PROPFIND_ADDRESSBOOKS = """<?xml version="1.0" encoding="utf-8"?>
|
|
25
|
+
<d:propfind xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
|
26
|
+
<d:prop>
|
|
27
|
+
<d:displayname/>
|
|
28
|
+
<d:resourcetype/>
|
|
29
|
+
</d:prop>
|
|
30
|
+
</d:propfind>"""
|
|
31
|
+
|
|
32
|
+
REPORT_ADDRESSBOOK_QUERY = """<?xml version="1.0" encoding="utf-8"?>
|
|
33
|
+
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
|
34
|
+
<d:prop>
|
|
35
|
+
<d:getetag/>
|
|
36
|
+
<card:address-data/>
|
|
37
|
+
</d:prop>
|
|
38
|
+
</card:addressbook-query>"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
42
|
+
def contacts() -> None:
|
|
43
|
+
"""Manage contacts via CardDAV."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _abook_url(cfg: Config, name: str) -> str:
|
|
47
|
+
return f"{cfg.carddav_principal.rstrip('/')}/{name}/"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _vcard_to_dict(card: vobject.base.Component) -> dict:
|
|
51
|
+
fn = getattr(card, "fn", None)
|
|
52
|
+
emails = [e.value for e in card.contents.get("email", [])]
|
|
53
|
+
tels = [t.value for t in card.contents.get("tel", [])]
|
|
54
|
+
return {
|
|
55
|
+
"uid": getattr(card, "uid", None).value if hasattr(card, "uid") else None,
|
|
56
|
+
"fn": fn.value if fn else None,
|
|
57
|
+
"emails": emails,
|
|
58
|
+
"phones": tels,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@verbose_option
|
|
63
|
+
@json_option
|
|
64
|
+
@contacts.command("list")
|
|
65
|
+
def list_addressbooks(json_output: bool) -> None:
|
|
66
|
+
"""List address books."""
|
|
67
|
+
cfg = load()
|
|
68
|
+
with spinner("Fetching address books", json_output):
|
|
69
|
+
with http_client(cfg, accept="application/xml") as http:
|
|
70
|
+
response = http.request(
|
|
71
|
+
"PROPFIND",
|
|
72
|
+
cfg.carddav_principal,
|
|
73
|
+
content=PROPFIND_ADDRESSBOOKS,
|
|
74
|
+
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
75
|
+
)
|
|
76
|
+
if response.status_code >= 400:
|
|
77
|
+
fail(f"PROPFIND failed: {response.status_code}")
|
|
78
|
+
|
|
79
|
+
root = ET.fromstring(response.text)
|
|
80
|
+
books = []
|
|
81
|
+
for resp in root.findall("d:response", CARDDAV_NS):
|
|
82
|
+
href = resp.findtext("d:href", "", CARDDAV_NS)
|
|
83
|
+
if href.rstrip("/") == cfg.carddav_principal.rstrip("/").split(cfg.url, 1)[-1].rstrip("/"):
|
|
84
|
+
continue
|
|
85
|
+
display = resp.findtext(".//d:displayname", "", CARDDAV_NS)
|
|
86
|
+
if display:
|
|
87
|
+
books.append({"name": Path(href.rstrip("/")).name, "displayname": display, "href": href})
|
|
88
|
+
render_addressbooks(books, json_output)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
_PROP_FIELDS = {
|
|
92
|
+
"name": ("FN",),
|
|
93
|
+
"email": ("EMAIL",),
|
|
94
|
+
"phone": ("TEL",),
|
|
95
|
+
"all": ("FN", "EMAIL", "TEL"),
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _build_search_report(query: str, fields: tuple[str, ...]) -> str:
|
|
100
|
+
"""Build a CardDAV addressbook-query REPORT body with text-match filters."""
|
|
101
|
+
safe = xml_escape(query)
|
|
102
|
+
filters = "\n".join(
|
|
103
|
+
f' <card:prop-filter name="{f}">'
|
|
104
|
+
f' <card:text-match collation="i;unicode-casemap" match-type="contains">{safe}</card:text-match>'
|
|
105
|
+
f' </card:prop-filter>'
|
|
106
|
+
for f in fields
|
|
107
|
+
)
|
|
108
|
+
return f"""<?xml version="1.0" encoding="utf-8"?>
|
|
109
|
+
<card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
|
110
|
+
<d:prop>
|
|
111
|
+
<d:getetag/>
|
|
112
|
+
<card:address-data/>
|
|
113
|
+
</d:prop>
|
|
114
|
+
<card:filter test="anyof">
|
|
115
|
+
{filters}
|
|
116
|
+
</card:filter>
|
|
117
|
+
</card:addressbook-query>"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@verbose_option
|
|
121
|
+
@json_option
|
|
122
|
+
@contacts.command()
|
|
123
|
+
@click.option("--addressbook", required=True)
|
|
124
|
+
@click.option("--query", required=True, help="Substring to match (case-insensitive).")
|
|
125
|
+
@click.option(
|
|
126
|
+
"--in",
|
|
127
|
+
"field",
|
|
128
|
+
type=click.Choice(list(_PROP_FIELDS.keys())),
|
|
129
|
+
default="all",
|
|
130
|
+
help="Which vCard property to search (default: all).",
|
|
131
|
+
)
|
|
132
|
+
def search(addressbook: str, query: str, field: str, json_output: bool) -> None:
|
|
133
|
+
"""Server-side search across contacts in an address book (CardDAV text-match)."""
|
|
134
|
+
cfg = load()
|
|
135
|
+
body = _build_search_report(query, _PROP_FIELDS[field])
|
|
136
|
+
with spinner(f"Searching '{query}' in {addressbook}", json_output):
|
|
137
|
+
with http_client(cfg, accept="application/xml") as http:
|
|
138
|
+
response = http.request(
|
|
139
|
+
"REPORT",
|
|
140
|
+
_abook_url(cfg, addressbook),
|
|
141
|
+
content=body,
|
|
142
|
+
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
143
|
+
)
|
|
144
|
+
if response.status_code >= 400:
|
|
145
|
+
fail(f"REPORT failed: {response.status_code}: {response.text}")
|
|
146
|
+
|
|
147
|
+
root = ET.fromstring(response.text)
|
|
148
|
+
out = []
|
|
149
|
+
for resp in root.findall("d:response", CARDDAV_NS):
|
|
150
|
+
href = resp.findtext("d:href", "", CARDDAV_NS)
|
|
151
|
+
data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
|
|
152
|
+
if not data:
|
|
153
|
+
continue
|
|
154
|
+
try:
|
|
155
|
+
card = vobject.readOne(data)
|
|
156
|
+
except Exception:
|
|
157
|
+
continue
|
|
158
|
+
item = _vcard_to_dict(card)
|
|
159
|
+
item["href"] = href
|
|
160
|
+
out.append(item)
|
|
161
|
+
render_contacts(out, json_output)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@verbose_option
|
|
165
|
+
@json_option
|
|
166
|
+
@contacts.command()
|
|
167
|
+
@click.option("--addressbook", required=True)
|
|
168
|
+
def cards(addressbook: str, json_output: bool) -> None:
|
|
169
|
+
"""List contacts in an address book."""
|
|
170
|
+
cfg = load()
|
|
171
|
+
with spinner(f"Fetching contacts from {addressbook}", json_output):
|
|
172
|
+
with http_client(cfg, accept="application/xml") as http:
|
|
173
|
+
response = http.request(
|
|
174
|
+
"REPORT",
|
|
175
|
+
_abook_url(cfg, addressbook),
|
|
176
|
+
content=REPORT_ADDRESSBOOK_QUERY,
|
|
177
|
+
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
178
|
+
)
|
|
179
|
+
if response.status_code >= 400:
|
|
180
|
+
fail(f"REPORT failed: {response.status_code}")
|
|
181
|
+
|
|
182
|
+
root = ET.fromstring(response.text)
|
|
183
|
+
out = []
|
|
184
|
+
for resp in root.findall("d:response", CARDDAV_NS):
|
|
185
|
+
href = resp.findtext("d:href", "", CARDDAV_NS)
|
|
186
|
+
data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
|
|
187
|
+
if not data:
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
card = vobject.readOne(data)
|
|
191
|
+
except Exception:
|
|
192
|
+
continue
|
|
193
|
+
item = _vcard_to_dict(card)
|
|
194
|
+
item["href"] = href
|
|
195
|
+
out.append(item)
|
|
196
|
+
render_contacts(out, json_output)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@verbose_option
|
|
200
|
+
@json_option
|
|
201
|
+
@contacts.command()
|
|
202
|
+
@click.option("--addressbook", required=True)
|
|
203
|
+
@click.option("--uid", required=True)
|
|
204
|
+
def get(addressbook: str, uid: str, json_output: bool) -> None:
|
|
205
|
+
"""Fetch a single contact by UID."""
|
|
206
|
+
cfg = load()
|
|
207
|
+
with spinner(f"Fetching contact {uid}", json_output):
|
|
208
|
+
with http_client(cfg, accept="application/xml") as http:
|
|
209
|
+
response = http.request(
|
|
210
|
+
"REPORT",
|
|
211
|
+
_abook_url(cfg, addressbook),
|
|
212
|
+
content=REPORT_ADDRESSBOOK_QUERY,
|
|
213
|
+
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
214
|
+
)
|
|
215
|
+
if response.status_code >= 400:
|
|
216
|
+
fail(f"REPORT failed: {response.status_code}")
|
|
217
|
+
|
|
218
|
+
root = ET.fromstring(response.text)
|
|
219
|
+
for resp in root.findall("d:response", CARDDAV_NS):
|
|
220
|
+
data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
|
|
221
|
+
if not data:
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
card = vobject.readOne(data)
|
|
225
|
+
except Exception:
|
|
226
|
+
continue
|
|
227
|
+
if hasattr(card, "uid") and card.uid.value == uid:
|
|
228
|
+
render_contact(_vcard_to_dict(card), json_output)
|
|
229
|
+
return
|
|
230
|
+
fail(f"contact not found: {uid}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@verbose_option
|
|
234
|
+
@json_option
|
|
235
|
+
@contacts.command()
|
|
236
|
+
@click.option("--addressbook", required=True)
|
|
237
|
+
@click.option("--uid", required=True)
|
|
238
|
+
@click.option("--local", "local", required=True, type=click.Path(dir_okay=False))
|
|
239
|
+
def export(addressbook: str, uid: str, local: str, json_output: bool) -> None:
|
|
240
|
+
"""Export a contact as a vCard file."""
|
|
241
|
+
cfg = load()
|
|
242
|
+
with spinner(f"Exporting contact {uid}", json_output):
|
|
243
|
+
with http_client(cfg, accept="application/xml") as http:
|
|
244
|
+
response = http.request(
|
|
245
|
+
"REPORT",
|
|
246
|
+
_abook_url(cfg, addressbook),
|
|
247
|
+
content=REPORT_ADDRESSBOOK_QUERY,
|
|
248
|
+
headers={"Depth": "1", "Content-Type": "application/xml"},
|
|
249
|
+
)
|
|
250
|
+
if response.status_code >= 400:
|
|
251
|
+
fail(f"REPORT failed: {response.status_code}")
|
|
252
|
+
|
|
253
|
+
root = ET.fromstring(response.text)
|
|
254
|
+
for resp in root.findall("d:response", CARDDAV_NS):
|
|
255
|
+
data = resp.findtext(".//card:address-data", "", CARDDAV_NS)
|
|
256
|
+
if not data:
|
|
257
|
+
continue
|
|
258
|
+
try:
|
|
259
|
+
card = vobject.readOne(data)
|
|
260
|
+
except Exception:
|
|
261
|
+
continue
|
|
262
|
+
if hasattr(card, "uid") and card.uid.value == uid:
|
|
263
|
+
Path(local).write_text(card.serialize())
|
|
264
|
+
render_status("exported", json_output, uid=uid, path=local)
|
|
265
|
+
return
|
|
266
|
+
fail(f"contact not found: {uid}")
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""File operations against the user's Nextcloud WebDAV root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from webdav4.client import ResourceAlreadyExists, ResourceNotFound
|
|
9
|
+
|
|
10
|
+
from nextcloud_cli.client import http_client, webdav_client
|
|
11
|
+
from nextcloud_cli.config import load
|
|
12
|
+
from nextcloud_cli.rendering import render_files_list, render_status
|
|
13
|
+
from nextcloud_cli.utils import (
|
|
14
|
+
CONTEXT_SETTINGS,
|
|
15
|
+
fail,
|
|
16
|
+
format_size,
|
|
17
|
+
json_option,
|
|
18
|
+
spinner,
|
|
19
|
+
verbose_option,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
24
|
+
def files() -> None:
|
|
25
|
+
"""Manage files via WebDAV."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@verbose_option
|
|
29
|
+
@json_option
|
|
30
|
+
@files.command("list")
|
|
31
|
+
@click.option("--path", default="/", help="Remote directory path.")
|
|
32
|
+
def list_(path: str, json_output: bool) -> None:
|
|
33
|
+
"""List files in a remote directory."""
|
|
34
|
+
cfg = load()
|
|
35
|
+
client = webdav_client(cfg)
|
|
36
|
+
try:
|
|
37
|
+
with spinner(f"Listing {path}", json_output):
|
|
38
|
+
entries = client.ls(path, detail=True)
|
|
39
|
+
except ResourceNotFound:
|
|
40
|
+
fail(f"path not found: {path}")
|
|
41
|
+
items = [
|
|
42
|
+
{
|
|
43
|
+
"name": Path(entry["name"]).name or entry["name"],
|
|
44
|
+
"path": entry["name"],
|
|
45
|
+
"type": entry["type"],
|
|
46
|
+
"size": entry.get("content_length") or 0,
|
|
47
|
+
"size_human": format_size(entry.get("content_length") or 0),
|
|
48
|
+
"modified": str(entry.get("modified")) if entry.get("modified") else None,
|
|
49
|
+
}
|
|
50
|
+
for entry in entries
|
|
51
|
+
]
|
|
52
|
+
render_files_list(items, path, json_output)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@verbose_option
|
|
56
|
+
@json_option
|
|
57
|
+
@files.command()
|
|
58
|
+
@click.option("--local", "local", required=True, type=click.Path(exists=True, dir_okay=False))
|
|
59
|
+
@click.option("--remote", required=True, help="Destination path on the server.")
|
|
60
|
+
def upload(local: str, remote: str, json_output: bool) -> None:
|
|
61
|
+
"""Upload a local file to the server."""
|
|
62
|
+
cfg = load()
|
|
63
|
+
client = webdav_client(cfg)
|
|
64
|
+
with spinner(f"Uploading {local} → {remote}", json_output):
|
|
65
|
+
client.upload_file(from_path=local, to_path=remote, overwrite=True)
|
|
66
|
+
render_status("uploaded", json_output, local=local, remote=remote)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@verbose_option
|
|
70
|
+
@json_option
|
|
71
|
+
@files.command()
|
|
72
|
+
@click.option("--remote", required=True, help="Source path on the server.")
|
|
73
|
+
@click.option("--local", "local", required=True, type=click.Path(dir_okay=False))
|
|
74
|
+
def download(remote: str, local: str, json_output: bool) -> None:
|
|
75
|
+
"""Download a remote file to the local filesystem."""
|
|
76
|
+
cfg = load()
|
|
77
|
+
client = webdav_client(cfg)
|
|
78
|
+
try:
|
|
79
|
+
with spinner(f"Downloading {remote} → {local}", json_output):
|
|
80
|
+
client.download_file(from_path=remote, to_path=local)
|
|
81
|
+
except ResourceNotFound:
|
|
82
|
+
fail(f"remote file not found: {remote}")
|
|
83
|
+
render_status("downloaded", json_output, remote=remote, local=local)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@verbose_option
|
|
87
|
+
@json_option
|
|
88
|
+
@files.command()
|
|
89
|
+
@click.option("--path", required=True, help="Path to delete.")
|
|
90
|
+
def delete(path: str, json_output: bool) -> None:
|
|
91
|
+
"""Delete a remote file or directory."""
|
|
92
|
+
cfg = load()
|
|
93
|
+
client = webdav_client(cfg)
|
|
94
|
+
try:
|
|
95
|
+
with spinner(f"Deleting {path}", json_output):
|
|
96
|
+
client.remove(path)
|
|
97
|
+
except ResourceNotFound:
|
|
98
|
+
fail(f"path not found: {path}")
|
|
99
|
+
render_status("deleted", json_output, path=path)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@verbose_option
|
|
103
|
+
@json_option
|
|
104
|
+
@files.command()
|
|
105
|
+
@click.option("--src", required=True, help="Current remote path.")
|
|
106
|
+
@click.option("--dst", required=True, help="New remote path.")
|
|
107
|
+
def move(src: str, dst: str, json_output: bool) -> None:
|
|
108
|
+
"""Move or rename a file."""
|
|
109
|
+
cfg = load()
|
|
110
|
+
client = webdav_client(cfg)
|
|
111
|
+
try:
|
|
112
|
+
with spinner(f"Moving {src} → {dst}", json_output):
|
|
113
|
+
client.move(src, dst)
|
|
114
|
+
except ResourceNotFound:
|
|
115
|
+
fail(f"source not found: {src}")
|
|
116
|
+
render_status("moved", json_output, src=src, dst=dst)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@verbose_option
|
|
120
|
+
@json_option
|
|
121
|
+
@files.command()
|
|
122
|
+
@click.option("--path", required=True, help="Directory to create.")
|
|
123
|
+
def mkdir(path: str, json_output: bool) -> None:
|
|
124
|
+
"""Create a remote directory."""
|
|
125
|
+
cfg = load()
|
|
126
|
+
client = webdav_client(cfg)
|
|
127
|
+
try:
|
|
128
|
+
with spinner(f"Creating {path}", json_output):
|
|
129
|
+
client.mkdir(path)
|
|
130
|
+
except ResourceAlreadyExists:
|
|
131
|
+
fail(f"already exists: {path}")
|
|
132
|
+
render_status("created", json_output, path=path)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@verbose_option
|
|
136
|
+
@json_option
|
|
137
|
+
@files.command()
|
|
138
|
+
@click.option("--query", required=True, help="Substring to match (server-side, OCS unified search).")
|
|
139
|
+
@click.option("--limit", default=None, type=click.IntRange(min=1), help="Max number of matches.")
|
|
140
|
+
def search(query: str, limit: int | None, json_output: bool) -> None:
|
|
141
|
+
"""Server-side filename search via the OCS unified search API."""
|
|
142
|
+
cfg = load()
|
|
143
|
+
url = f"{cfg.url.rstrip('/')}/ocs/v2.php/search/providers/files/search"
|
|
144
|
+
params: dict[str, str | int] = {"term": query}
|
|
145
|
+
if limit is not None:
|
|
146
|
+
params["limit"] = limit
|
|
147
|
+
with spinner(f"Searching for '{query}'", json_output):
|
|
148
|
+
with http_client(cfg) as http:
|
|
149
|
+
response = http.get(url, params=params)
|
|
150
|
+
if response.status_code >= 400:
|
|
151
|
+
fail(f"search failed: {response.status_code}: {response.text}")
|
|
152
|
+
payload = response.json().get("ocs", {}).get("data", {})
|
|
153
|
+
entries = payload.get("entries", []) or []
|
|
154
|
+
matches = [
|
|
155
|
+
{
|
|
156
|
+
"name": entry.get("title"),
|
|
157
|
+
"path": entry.get("attributes", {}).get("path") or entry.get("subline") or "",
|
|
158
|
+
"resourceUrl": entry.get("resourceUrl"),
|
|
159
|
+
"fileId": entry.get("attributes", {}).get("fileId"),
|
|
160
|
+
"icon": entry.get("icon"),
|
|
161
|
+
}
|
|
162
|
+
for entry in entries
|
|
163
|
+
]
|
|
164
|
+
if limit is not None:
|
|
165
|
+
matches = matches[:limit]
|
|
166
|
+
render_files_list(matches, f"matches for '{query}'", json_output)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Nextcloud Notes app — REST/JSON API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from nextcloud_cli.client import http_client
|
|
9
|
+
from nextcloud_cli.config import load
|
|
10
|
+
from nextcloud_cli.rendering import render_note, render_notes_list, render_status
|
|
11
|
+
from nextcloud_cli.utils import CONTEXT_SETTINGS, fail, json_option, spinner, verbose_option
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
15
|
+
def notes() -> None:
|
|
16
|
+
"""Manage notes (requires the Nextcloud Notes app)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _handle(response: httpx.Response) -> dict | list:
|
|
20
|
+
if response.status_code == 404:
|
|
21
|
+
fail("note not found")
|
|
22
|
+
if response.status_code == 401:
|
|
23
|
+
fail("unauthorized — check your app password")
|
|
24
|
+
if response.status_code >= 400:
|
|
25
|
+
fail(f"notes API error {response.status_code}: {response.text}")
|
|
26
|
+
return response.json() if response.content else {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@verbose_option
|
|
30
|
+
@json_option
|
|
31
|
+
@notes.command("list")
|
|
32
|
+
@click.option("--category", default=None, help="Filter by category.")
|
|
33
|
+
@click.option("--limit", default=None, type=click.IntRange(min=1), help="Max number of notes to fetch.")
|
|
34
|
+
def list_(category: str | None, limit: int | None, json_output: bool) -> None:
|
|
35
|
+
"""List all notes."""
|
|
36
|
+
cfg = load()
|
|
37
|
+
params: dict[str, str | int] = {}
|
|
38
|
+
if category is not None:
|
|
39
|
+
params["category"] = category
|
|
40
|
+
if limit is not None:
|
|
41
|
+
params["chunkSize"] = limit
|
|
42
|
+
with spinner("Fetching notes", json_output):
|
|
43
|
+
with http_client(cfg) as http:
|
|
44
|
+
data = _handle(http.get(cfg.notes_api_url, params=params or None))
|
|
45
|
+
notes_list = data if isinstance(data, list) else [data]
|
|
46
|
+
if limit is not None:
|
|
47
|
+
notes_list = notes_list[:limit]
|
|
48
|
+
render_notes_list(notes_list, json_output)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@verbose_option
|
|
52
|
+
@json_option
|
|
53
|
+
@notes.command()
|
|
54
|
+
@click.option("--id", "note_id", required=True, type=int)
|
|
55
|
+
def get(note_id: int, json_output: bool) -> None:
|
|
56
|
+
"""Fetch a single note by ID."""
|
|
57
|
+
cfg = load()
|
|
58
|
+
with spinner(f"Fetching note {note_id}", json_output):
|
|
59
|
+
with http_client(cfg) as http:
|
|
60
|
+
data = _handle(http.get(f"{cfg.notes_api_url}/{note_id}"))
|
|
61
|
+
render_note(data if isinstance(data, dict) else {}, json_output)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@verbose_option
|
|
65
|
+
@json_option
|
|
66
|
+
@notes.command()
|
|
67
|
+
@click.option("--title", required=True)
|
|
68
|
+
@click.option("--content", default="", help="Body of the note.")
|
|
69
|
+
@click.option("--category", default="", help="Category folder.")
|
|
70
|
+
def create(title: str, content: str, category: str, json_output: bool) -> None:
|
|
71
|
+
"""Create a new note."""
|
|
72
|
+
cfg = load()
|
|
73
|
+
payload = {"title": title, "content": content, "category": category}
|
|
74
|
+
with spinner(f"Creating note '{title}'", json_output):
|
|
75
|
+
with http_client(cfg) as http:
|
|
76
|
+
data = _handle(http.post(cfg.notes_api_url, json=payload))
|
|
77
|
+
if json_output:
|
|
78
|
+
from nextcloud_cli.utils import emit
|
|
79
|
+
|
|
80
|
+
emit(data)
|
|
81
|
+
else:
|
|
82
|
+
render_status("note created", json_output, id=data.get("id"), title=data.get("title"))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@verbose_option
|
|
86
|
+
@json_option
|
|
87
|
+
@notes.command()
|
|
88
|
+
@click.option("--id", "note_id", required=True, type=int)
|
|
89
|
+
@click.option("--title", default=None)
|
|
90
|
+
@click.option("--content", default=None)
|
|
91
|
+
@click.option("--category", default=None)
|
|
92
|
+
def edit(note_id: int, title: str | None, content: str | None, category: str | None, json_output: bool) -> None:
|
|
93
|
+
"""Update an existing note. Only provided fields change."""
|
|
94
|
+
cfg = load()
|
|
95
|
+
payload = {k: v for k, v in {"title": title, "content": content, "category": category}.items() if v is not None}
|
|
96
|
+
if not payload:
|
|
97
|
+
fail("nothing to update — provide at least one of --title/--content/--category")
|
|
98
|
+
with spinner(f"Updating note {note_id}", json_output):
|
|
99
|
+
with http_client(cfg) as http:
|
|
100
|
+
data = _handle(http.put(f"{cfg.notes_api_url}/{note_id}", json=payload))
|
|
101
|
+
if json_output:
|
|
102
|
+
from nextcloud_cli.utils import emit
|
|
103
|
+
|
|
104
|
+
emit(data)
|
|
105
|
+
else:
|
|
106
|
+
render_status("note updated", json_output, id=note_id)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@verbose_option
|
|
110
|
+
@json_option
|
|
111
|
+
@notes.command()
|
|
112
|
+
@click.option("--id", "note_id", required=True, type=int)
|
|
113
|
+
def delete(note_id: int, json_output: bool) -> None:
|
|
114
|
+
"""Delete a note."""
|
|
115
|
+
cfg = load()
|
|
116
|
+
with spinner(f"Deleting note {note_id}", json_output):
|
|
117
|
+
with http_client(cfg) as http:
|
|
118
|
+
_handle(http.delete(f"{cfg.notes_api_url}/{note_id}"))
|
|
119
|
+
render_status("deleted", json_output, id=note_id)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Login / logout commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from nextcloud_cli import config as cfg_module
|
|
11
|
+
from nextcloud_cli.rendering import render_status
|
|
12
|
+
from nextcloud_cli.utils import CONTEXT_SETTINGS, json_option, spinner, verbose_option
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve(flag_value: str | None, env_var: str, prompt: str, *, hidden: bool = False, default: str | None = None) -> str:
|
|
16
|
+
"""flag value > env var > interactive prompt."""
|
|
17
|
+
if flag_value:
|
|
18
|
+
return flag_value
|
|
19
|
+
env_value = os.environ.get(env_var)
|
|
20
|
+
if env_value:
|
|
21
|
+
return env_value
|
|
22
|
+
return click.prompt(prompt, hide_input=hidden, default=default, type=str)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.command(context_settings=CONTEXT_SETTINGS)
|
|
26
|
+
@verbose_option
|
|
27
|
+
@json_option
|
|
28
|
+
@click.option("--url", default=None, help="Nextcloud server URL.")
|
|
29
|
+
@click.option("--username", default=None, help="Nextcloud username.")
|
|
30
|
+
@click.option("--password", default=None, help="App password (NOT your account password).")
|
|
31
|
+
@click.option("--timezone", default=None, help="IANA timezone (e.g. Europe/Paris).")
|
|
32
|
+
def login(url: str | None, username: str | None, password: str | None, timezone: str | None, json_output: bool) -> None:
|
|
33
|
+
"""Log in to a Nextcloud server."""
|
|
34
|
+
url = _resolve(url, "NEXTCLOUD_URL", "Nextcloud URL").rstrip("/")
|
|
35
|
+
username = _resolve(username, "NEXTCLOUD_USER", "Username")
|
|
36
|
+
password = _resolve(password, "NEXTCLOUD_TOKEN", "App password", hidden=True)
|
|
37
|
+
timezone = _resolve(timezone, "NEXTCLOUD_TIMEZONE", "Timezone", default="UTC")
|
|
38
|
+
|
|
39
|
+
test_url = f"{url}/remote.php/dav/files/{username}/"
|
|
40
|
+
try:
|
|
41
|
+
with spinner(f"Authenticating to {url}", json_output):
|
|
42
|
+
response = httpx.request(
|
|
43
|
+
"PROPFIND",
|
|
44
|
+
test_url,
|
|
45
|
+
auth=(username, password),
|
|
46
|
+
headers={"Depth": "0"},
|
|
47
|
+
timeout=15.0,
|
|
48
|
+
)
|
|
49
|
+
except httpx.HTTPError as exc:
|
|
50
|
+
raise click.ClickException(f"could not reach server: {exc}") from exc
|
|
51
|
+
if response.status_code in (401, 403):
|
|
52
|
+
raise click.ClickException("authentication failed — check your username and app password")
|
|
53
|
+
if response.status_code >= 400:
|
|
54
|
+
raise click.ClickException(f"server returned HTTP {response.status_code}")
|
|
55
|
+
|
|
56
|
+
cfg_module.save(url=url, username=username, password=password, timezone=timezone)
|
|
57
|
+
render_status("logged-in", json_output, url=url, username=username, timezone=timezone)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@click.command(context_settings=CONTEXT_SETTINGS)
|
|
61
|
+
@verbose_option
|
|
62
|
+
@json_option
|
|
63
|
+
def logout(json_output: bool) -> None:
|
|
64
|
+
"""Erase stored credentials."""
|
|
65
|
+
cfg_module.clear()
|
|
66
|
+
render_status("logged-out", json_output)
|