import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import quote, urljoin, urlparse
import dateutil.parser
import requests
from platypush.common.notes import Note, NoteCollection
from platypush.config import Config
from platypush.plugins.notes import ApiSettings, BaseNotePlugin, Results
from platypush.utils import utcnow
[docs]
@dataclass
class Settings:
"""
Plugin settings for Nextcloud Notes.
"""
notes_path: str = 'Notes'
file_suffix: str = '.md'
[docs]
class NextcloudNotesPlugin(BaseNotePlugin):
r"""
Plugin to interact with `Nextcloud Notes
<https://apps.nextcloud.com/apps/notes>`_,
"""
_api_settings = ApiSettings(
supports_notes_limit=True,
supports_notes_offset=False,
supports_collections_limit=False,
supports_collections_offset=False,
supports_search_limit=False,
supports_search_offset=False,
supports_search=False,
)
[docs]
def __init__(
self,
*args,
url: Optional[str] = None,
username: Optional[str] = None,
password: Optional[str] = None,
**kwargs,
):
"""
If the :class:`platypush.plugins.nextcloud.NextcloudPlugin` is
installed and configured, and you intend to use the same instance, then
you can skip this configuration.
:param url: The URL of the Nextcloud instance (e.g.,
`https://nextcloud.example.com`).
:param username: The username to authenticate with.
:param password: The password to authenticate with. It is advised to
use a dedicated app password instead of your main account password
(this is actually a requirement if you have enabled 2FA).
"""
super().__init__(*args, **kwargs)
nc_config = Config.get('nextcloud') or {}
self.url = url or nc_config.get('url', '')
self.username = username or nc_config.get('username', '')
self.password = password or nc_config.get('password', '')
assert (
self.url
), 'Nextcloud URL is required, either in this plugin or in the Nextcloud plugin configuration'
assert (
self.username
), 'Nextcloud username is required, either in this plugin or in the Nextcloud plugin configuration'
assert (
self.password
), 'Nextcloud password is required, either in this plugin or in the Nextcloud plugin configuration'
self.settings = self._get_settings()
def _get_settings(self) -> Settings:
settings = Settings()
try:
response = self._api_exec('GET', 'settings').json()
settings.notes_path = response.get('notesPath', settings.notes_path)
settings.file_suffix = response.get('fileSuffix', settings.file_suffix)
except requests.RequestException:
# Notes API versions <1.2 don't have the settings endpoint.
# Use default settings in that case.
...
return settings
def _api_url(self, path: str = '') -> str:
return '/'.join(
[
self.url.rstrip('/'),
f'index.php/apps/notes/api/v1/{path.lstrip("/")}'.rstrip('/'),
]
)
@property
def _webdav_path(self):
"""
Base WebDAV path for the notes folder in Nextcloud.
"""
url = urlparse(self.url)
return '/'.join(
[
url.path.rstrip('/'),
f'remote.php/dav/files/{self.username}/{self.settings.notes_path}/',
]
)
@property
def _webdav_url(self):
"""
Base WebDAV URL for the notes folder in Nextcloud.
"""
return '/'.join(
[
self.url.rstrip('/'),
f'remote.php/dav/files/{self.username}/{self.settings.notes_path}/',
]
)
def _api_exec(
self,
method: str,
path: str = '',
params: Optional[Dict[str, Any]] = None,
**kwargs,
) -> requests.Response:
"""
Execute a request to the Nextcloud Notes API.
"""
url = self._api_url(path)
auth = kwargs.pop('auth', (self.username, self.password))
self.logger.debug(
'Calling Nextcloud Notes API: %s %s with params: %s',
method,
url,
params,
)
response = requests.request(
method, url, params=params, auth=auth, timeout=self._timeout, **kwargs
)
try:
response.raise_for_status()
except requests.RequestException as e:
self.logger.error(
'Failed to execute request %s %s: status=%s, error=%s, body=%s',
method,
url,
response.status_code if response else 'N/A',
e,
response.text if response else 'N/A',
)
raise RuntimeError(f'Failed to execute request {method} {url}: {e}') from e
return response
def _to_note(self, data: dict) -> Note:
dt = datetime.fromtimestamp(data['modified']) if data.get('modified') else None
return Note(
**{
'id': str(data.get('id', '')),
'plugin': self._plugin_name,
'title': data.get('title', ''),
'content': data.get('content'),
'parent': (
self._to_collection(data['category'])
if data.get('category')
else None
),
# No creation time in the API, so we set it to epoch
'created_at': datetime.fromtimestamp(0),
'updated_at': dt,
}
)
def _to_collection(self, title: str) -> NoteCollection:
return NoteCollection(
id=title,
plugin=self._plugin_name,
title=title,
)
def _fetch_note(self, note_id: Any, *_, **__) -> Optional[Note]:
response = self._api_exec('GET', f'notes/{note_id}')
return self._to_note(response.json()) # type: ignore[return-value]
def _fetch_notes(self, *_, limit: Optional[int] = None, **__) -> List[Note]:
"""
Fetch notes from the Nextcloud Notes API.
"""
return [
self._to_note(note)
for note in (
self._api_exec(
'GET',
'notes',
params={
'exclude': 'content',
'chunkSize': limit or 10000,
# TODO Support chunkCursor
},
).json()
or {}
)
]
def _create_note(
self,
title: str,
content: str,
*_,
parent: Optional[Any] = None,
**__,
) -> Note:
response = self._api_exec(
'POST',
'notes',
json={
'title': title,
'category': parent,
'content': content,
},
)
return self._to_note(response.json())
def _edit_note(
self,
note_id: Any,
*_,
title: Optional[str] = None,
content: Optional[str] = None,
parent: Optional[Any] = None,
**__,
) -> Note:
data = {}
if title is not None:
data['title'] = title
if content is not None:
data['content'] = content
if parent is not None:
data['category'] = parent
response = self._api_exec('PUT', f'notes/{note_id}', json=data)
return self._to_note(response.json())
def _delete_note(self, note_id: Any, *_, **__):
self._api_exec('DELETE', f'notes/{note_id}')
def _fetch_collections(self, *_, **__) -> List[NoteCollection]:
"""
Retrieve the collections in the Notes folder using WebDAV.
"""
response = requests.request(
method='PROPFIND',
url=self._webdav_url,
auth=(self.username, self.password),
timeout=self._timeout,
headers={
'Content-Type': 'application/xml; charset="utf-8"',
'Depth': '100',
},
)
response.raise_for_status()
tree = ET.fromstring(response.content)
namespaces = {'d': 'DAV:'}
responses = tree.findall('.//d:response', namespaces)
collections = []
for resp in responses:
href_elem = resp.find('d:href', namespaces)
if href_elem is None or not href_elem.text:
continue
href = str(href_elem.text.strip('/'))
collection_id = re.sub(
fr'^{re.escape(self._webdav_path).strip("/")}', '', href
).strip('/')
collection_id = collection_id.rstrip(self.settings.file_suffix)
props = resp.find('d:propstat/d:prop', namespaces)
if props is None:
continue
is_collection = (
props.find('d:resourcetype/d:collection', namespaces) is not None
)
last_modified_elem = props.find('d:getlastmodified', namespaces)
last_modified = utcnow()
if last_modified_elem is not None and last_modified_elem.text:
last_modified = dateutil.parser.parse(last_modified_elem.text)
if not is_collection:
continue
collections.append(
NoteCollection(
id=collection_id,
plugin=self._plugin_name,
title=collection_id,
# WebDAV doesn't provide the creation time, so we set it to epoch
created_at=datetime.fromtimestamp(0),
updated_at=last_modified,
)
)
return collections
def _fetch_collection(
self, collection_id: Any, *_, **__
) -> Optional[NoteCollection]:
"""
Fetch a collection (folder) by its ID.
Note that the Nextcloud API does not provide a direct way to fetch
collections. Instead, we use the WebDAV API to list the contents of
the Notes folder.
"""
return next(
(
collection
for collection in self._fetch_collections()
if collection.id == collection_id
),
None,
)
def _create_collection(
self,
title: str,
*_,
parent: Optional[Any] = None,
**__,
) -> NoteCollection:
"""
This uses the WebDAV API to create a new collection (folder) in the Notes folder.
"""
collection_id = '/'.join(
[
(parent or '').rstrip('/'),
title.strip('/'),
]
).strip('/')
response = requests.request(
method='MKCOL',
url=urljoin(self._webdav_url, collection_id),
auth=(self.username, self.password),
headers={'Content-Type': 'application/xml; charset="utf-8"'},
timeout=self._timeout,
)
response.raise_for_status()
return NoteCollection(
id=collection_id,
plugin=self._plugin_name,
title=collection_id,
created_at=utcnow(),
updated_at=utcnow(),
)
def _edit_collection(
self,
collection_id: Any,
*_,
title: Optional[str] = None,
parent: Optional[Any] = None,
**__,
) -> NoteCollection:
"""
Change a directory name using the WebDAV API.
"""
if title is None:
raise ValueError('Title is required to edit a collection')
src_path = collection_id.strip('/')
dest_path = '/'.join(
[
parent.rstrip('/') if parent else '',
title.strip('/'),
]
).strip('/')
webdav_url = self._webdav_url.rstrip('/')
src_url = '/'.join([webdav_url, src_path.strip('/')])
dest_url = '/'.join(
[
webdav_url,
*[quote(part) for part in dest_path.split('/') if part],
]
)
self.logger.debug('Moving collection "%s" to "%s"', src_url, dest_url)
response = requests.request(
method='MOVE',
url=src_url,
auth=(self.username, self.password),
headers={
'Destination': dest_url,
'Overwrite': 'T',
},
timeout=self._timeout,
)
response.raise_for_status()
return NoteCollection(
id=src_path,
plugin=self._plugin_name,
title=src_path,
created_at=datetime.fromtimestamp(0), # No creation time in WebDAV
updated_at=utcnow(),
)
def _delete_collection(self, collection_id: Any, *_: Any, **__: Any):
"""
Delete a collection (folder) by its ID.
This uses the WebDAV API to delete the folder in the Notes folder.
"""
url = '/'.join(
[
self._webdav_url.rstrip('/'),
collection_id.strip('/'),
]
)
response = requests.request(
method='DELETE',
url=url,
auth=(self.username, self.password),
timeout=self._timeout,
)
response.raise_for_status()
self.logger.info('Deleted collection: %s', collection_id)
def _search(self, *_, **__) -> Results:
"""
Search is not implemented in the Nextcloud Notes API.
Fallback on the internal database search instead.
"""
raise NotImplementedError(
'Search is not implemented in the Nextcloud Notes API. '
'Fallback on the internal database search instead.'
)
# vim:sw=4:ts=4:et: