Source code for platypush.plugins.joplin

from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin

import requests

from platypush.common.notes import Note, NoteCollection, NoteSource
from platypush.plugins.notes import (
    ApiSettings,
    BaseNotePlugin,
    Item,
    ItemType,
    Results,
)


[docs] class JoplinPlugin(BaseNotePlugin): r""" Plugin to interact with `Joplin <https://joplinapp.org/>`_, a free and open-source note-taking application. ## Advised setup Joplin is mainly intended as a desktop application, with support for synchronization across multiple devices via various backends. This plugin is designed to interact with the Joplin API exposed by the "desktop" application - the same one used by the `Joplin Web Clipper extension <https://joplinapp.org/clipper/>`_. This can be achieved in two ways: 1. Using the Joplin desktop application 2. Using Joplin in headless mode ### Using the Joplin desktop application To use the Joplin desktop application, you need to enable the Web Clipper service in the Joplin settings. This will expose an HTTP API on the port 41184 on your local machine. Note that the Joplin desktop application must be running for this plugin to work, and by default it will only accept requests from the local machine. If you want to run the Platypush server on a different machine, you can e.g. open an SSH tunnel to the Joplin machine: .. code-block:: bash ssh -L 41184:localhost:41184 user@joplin-machine ### Using Joplin in headless mode The downside of the Joplin desktop application is that it must be running on a live desktop session for the plugin to work. Unfortunately, Joplin does not provide an official headless mode, but there is `a community project <https://github.com/jspiers/headless-joplin>`_ that allows you to run Joplin in a Docker container in headless mode, exposing the same HTTP API as the desktop application. It's first advised to run and configure your Joplin on the desktop app. Then locate the Joplin profile directory (usually ``~/.config/joplin-desktop`` on Linux, but depending on the installed version it could also be named ``Joplin`` or ``joplin``) and copy the file named ``settings.json`` to ``~/.config/joplin/settings.json`` on the machine where you run the headless Joplin container. Optionally, you can also copy the ``database.sqlite`` file from the desktop Joplin profile directory to the headless Joplin profile, but make sure that the desktop instance and the headless instance run the same version of Joplin, otherwise you might run into database incompatibility issues. #### Credentials If you opt not to copy the database file to the headless Joplin profile, then you may have to manually set up the credentials in a separate file named ``secrets.json`` in the Joplin profile directory. This will contain both the API token and the passwords for any connected synchronization services. For example: .. code-block:: json { "api.token": "your_api_token_here", "sync.1.password": "your_sync_password_here" } Note: 1. The ``api.token`` field is mandatory, and it should match the one you configure in this plugin. 2. ``sync.<n>.password`` should be such that ``<n>`` is the index of the synchronization target configured in ``settings.json`` (e.g. Dropbox, Nextcloud, S3, Joplin Cloud, a local Joplin server, etc.). #### Running the service .. code-block:: bash docker run --rm \ --name joplin-headless \ -v ~/.config/joplin:/home/node/.config/joplin \ -v ~/.config/joplin/secrets.json:/run/secrets/joplin-config.json:ro \ -p 41184:80 \ jspiers/headless-joplin:2.13.2-node-20.11.1 #### Synchronization Unlike the Joplin desktop application, the headless Joplin instance does not provide a periodic synchronization mechanism. But you can schedule a cronjob to periodically run synchronization each e.g. 5 minutes: .. code-block:: bash crontab -e # Add the following line to the crontab file */5 * * * * /usr/bin/docker exec joplin-headless joplin sync It is advised to have at least a remote synchronization target, and have the same target configured both in the Joplin desktop or mobile application and in the headless instance, so that you can keep your notes in sync across all of your devices, even though this plugin will only interact with the headless instance. """ _api_settings = ApiSettings( supports_notes_limit=True, supports_notes_offset=True, supports_collections_limit=True, supports_collections_offset=True, supports_search_limit=True, supports_search_offset=True, supports_search=True, ) _default_note_fields = ( 'id', 'parent_id', 'title', 'created_time', 'updated_time', 'latitude', 'longitude', 'altitude', 'author', 'source', 'source_url', 'source_application', ) _default_collection_fields = ( 'id', 'title', 'parent_id', 'created_time', 'updated_time', ) # Mapping of the internal note fields to the Joplin API fields. _joplin_search_fields = { 'id': 'id', 'title': 'title', 'content': 'body', 'type': 'type', 'parent': 'notebook', 'latitude': 'latitude', 'longitude': 'longitude', 'altitude': 'altitude', 'source': 'sourceurl', } # Mapping of ItemType values to Joplin API item types. _joplin_item_types = { ItemType.NOTE: 'note', ItemType.COLLECTION: 'folder', ItemType.TAG: 'tag', }
[docs] def __init__(self, *args, host: str, port: int = 41184, token: str, **kwargs): """ :param host: The hostname or IP address of your Joplin application. :param port: The port number of your Joplin application (default: 41184). :param token: The access token of your Joplin server. """ super().__init__(*args, **kwargs) self.host = host self.port = port self.token = token
def _base_url(self, path: str = '') -> str: return urljoin( f'http://{self.host}:{self.port}/', path.lstrip('/'), ) def _exec(self, method: str, path: str = '', **kwargs) -> Optional[dict]: """ Execute a request to the Joplin API. """ url = self._base_url(path) params = kwargs.pop('params', {}) self.logger.debug( 'Calling Joplin API: %s %s with params: %s', method, url, params, ) params['token'] = self.token response = requests.request( method, url, params=params, timeout=self._timeout, **kwargs ) if not response.ok: err = response.text try: rs = response.json() err = rs.get('error', err).splitlines()[0] except (TypeError, ValueError): pass raise RuntimeError( f'Joplin API request failed with status {response.status_code}: {err}' ) try: return response.json() except ValueError: return None def _parse_source(self, data: dict) -> Optional[NoteSource]: has_source = any( key in data for key in ('source', 'source_url', 'source_application') ) if not has_source: return None return NoteSource( name=data.get('source'), url=data.get('source_url'), app=data.get('source_application'), ) @staticmethod def _parse_time(t: Optional[int]) -> Optional[datetime]: """ Parse a Joplin timestamp (in milliseconds) into a datetime object. """ if t is None: return None return datetime.fromtimestamp(t / 1000) def _to_note(self, data: dict) -> Note: parent_id = data.get('parent_id') parent = None if parent_id: parent = self._collections.get( parent_id, NoteCollection(id=parent_id, plugin=self._plugin_name, title=''), ) return Note( **{ 'id': data.get('id', ''), 'plugin': self._plugin_name, 'title': data.get('title', ''), 'description': data.get('description'), 'content': data.get('body'), 'parent': parent, 'source': self._parse_source(data), **self._parse_geo(data), 'created_at': self._parse_time(data.get('created_time')), 'updated_at': self._parse_time(data.get('updated_time')), } ) def _to_collection(self, data: dict) -> NoteCollection: """ Convert a Joplin collection (folder) to a NoteCollection. """ return NoteCollection( id=data.get('id', ''), plugin=self._plugin_name, title=data.get('title', ''), description=data.get('description'), created_at=self._parse_time(data.get('created_time')), updated_at=self._parse_time(data.get('updated_time')), ) def _offset_to_page( self, offset: Optional[int], limit: Optional[int] ) -> Optional[int]: """ Convert an offset to a page number for Joplin API requests. """ limit = limit or 100 # Default limit if not provided if offset is None: return None return (offset // limit) + 1 if limit > 0 else 1 def _fetch_note(self, note_id: Any, *_, **__) -> Optional[Note]: note = None err = None try: note = self._exec( 'GET', f'notes/{note_id}', params={ 'fields': ','.join( [ *self._default_note_fields, 'body', # Include body content ] ) }, ) except RuntimeError as e: err = e if not note: self.logger.warning( 'Note with ID %s could not be fetched: %s', note_id, err if err else 'Unknown error', ) return None return self._to_note(note) # type: ignore[return-value] def _fetch_notes( self, *_, limit: Optional[int] = None, offset: Optional[int] = None, **__ ) -> List[Note]: """ Fetch notes from Joplin. """ return [ self._to_note(note) for note in ( self._exec( 'GET', 'notes', params={ 'fields': ','.join(self._default_note_fields), 'limit': limit, 'page': self._offset_to_page(offset=offset, limit=limit), }, ) or {} ).get('items', []) ] def _create_note( self, title: str, content: str, *_, parent: Optional[Any] = None, geo: Optional[dict] = None, source: Optional[NoteSource] = None, author: Optional[str] = None, **__, ) -> Note: data = { 'title': title, 'body': content, 'parent_id': parent, 'latitude': geo.get('latitude') if geo else None, 'longitude': geo.get('longitude') if geo else None, 'altitude': geo.get('altitude') if geo else None, 'author': author or '', } if source: data['source'] = source.name or '' data['source_url'] = source.url or '' data['source_application'] = source.app or '' response = self._exec('POST', 'notes', json=data) assert response, 'Failed to create note' return self._to_note(response) def _edit_note( self, note_id: Any, *_, title: Optional[str] = None, content: Optional[str] = None, parent: Optional[Any] = None, geo: Optional[dict] = None, source: Optional[NoteSource] = None, author: Optional[str] = None, **__, ) -> Note: data = {} if title is not None: data['title'] = title if content is not None: data['body'] = content if parent is not None: data['parent_id'] = parent if geo: data['latitude'] = geo.get('latitude') data['longitude'] = geo.get('longitude') data['altitude'] = geo.get('altitude') if author is not None: data['author'] = author if source: data['source'] = source.name or '' data['source_url'] = source.url or '' data['source_application'] = source.app or '' response = self._exec('PUT', f'notes/{note_id}', json=data) assert response, 'Failed to edit note' return self._to_note(response) def _delete_note(self, note_id: Any, *_, **__): self._exec('DELETE', f'notes/{note_id}') def _fetch_collection( self, collection_id: Any, *_, **__ ) -> Optional[NoteCollection]: """ Fetch a collection (folder) by its ID. """ collection_data = self._exec( 'GET', f'folders/{collection_id}', params={'fields': ','.join(self._default_collection_fields)}, ) if not collection_data: self.logger.warning( 'Collection with ID %s could not be fetched', collection_id ) return None return self._to_collection(collection_data) def _fetch_collections( self, *_, limit: Optional[int] = None, offset: Optional[int] = None, **__ ) -> List[NoteCollection]: """ Fetch collections (folders) from Joplin. """ collections_data = ( self._exec( 'GET', 'folders', params={ 'fields': ','.join(self._default_collection_fields), 'limit': limit, 'page': self._offset_to_page(offset=offset, limit=limit), }, ) or {} ).get('items', []) return [self._to_collection(coll) for coll in collections_data] def _create_collection( self, title: str, *_, parent: Optional[Any] = None, **__, ) -> NoteCollection: response = self._exec( 'POST', 'folders', json={ 'title': title, 'parent_id': parent, }, ) assert response, 'Failed to create collection' return self._to_collection(response) def _edit_collection( self, collection_id: Any, *_, title: Optional[str] = None, parent: Optional[Any] = None, **__, ) -> NoteCollection: data = {} if title is not None: data['title'] = title if parent is not None: data['parent_id'] = parent response = self._exec('PUT', f'folders/{collection_id}', json=data) assert response, 'Failed to edit collection' return self._to_collection(response) def _delete_collection(self, collection_id: Any, *_: Any, **__: Any): """ Delete a collection (folder) by its ID. """ self._exec('DELETE', f'folders/{collection_id}') def _build_search_query( self, query: str, *, include_terms: Optional[Dict[str, Any]] = None, exclude_terms: Optional[Dict[str, Any]] = None, created_before: Optional[datetime] = None, created_after: Optional[datetime] = None, updated_before: Optional[datetime] = None, updated_after: Optional[datetime] = None, ) -> str: query += ' ' + ' '.join( [ f'{self._joplin_search_fields.get(k, k)}:"{v}"' for k, v in (include_terms or {}).items() ] ) query += ' ' + ' '.join( [ f'-{self._joplin_search_fields.get(k, k)}:"{v}"' for k, v in (exclude_terms or {}).items() ] ) if created_before: query += f' -created:{created_before.strftime("%Y%m%d")}' if created_after: query += f' created:{created_after.strftime("%Y%m%d")}' if updated_before: query += f' -updated:{updated_before.strftime("%Y%m%d")}' if updated_after: query += f' updated:{updated_after.strftime("%Y%m%d")}' return query.strip() def _search( self, query: str, *_, item_type: ItemType, include_terms: Optional[Dict[str, Any]] = None, exclude_terms: Optional[Dict[str, Any]] = None, created_before: Optional[datetime] = None, created_after: Optional[datetime] = None, updated_before: Optional[datetime] = None, updated_after: Optional[datetime] = None, limit: Optional[int] = None, offset: Optional[int] = 0, **__, ) -> Results: """ Search for notes or collections based on the provided query and filters. """ api_item_type = self._joplin_item_types.get(item_type) assert ( api_item_type ), f'Invalid item type: {item_type}. Supported types: {list(self._joplin_item_types.keys())}' limit = limit or 100 results = ( self._exec( 'GET', 'search', params={ 'type': api_item_type, 'limit': limit, 'page': self._offset_to_page(offset=offset, limit=limit), 'fields': ','.join( self._default_note_fields if item_type == ItemType.NOTE else self._default_collection_fields ), 'query': self._build_search_query( query, include_terms=include_terms, exclude_terms=exclude_terms, created_before=created_before, created_after=created_after, updated_before=updated_before, updated_after=updated_after, ), }, ) or {} ) return Results( has_more=bool(results.get('has_more')), items=[ Item( type=item_type, item=( self._to_note(result) if item_type == ItemType.NOTE else self._to_collection(result) ), ) for result in results.get('items', []) ], )
# vim:sw=4:ts=4:et: