From edfdf06b2e28c24691317dc4bab8a4b8b628d150 Mon Sep 17 00:00:00 2001 From: snyk-bot <snyk-bot@snyk.io> Date: Wed, 16 Apr 2025 07:10:20 +0000 Subject: [PATCH 01/18] fix: upgrade axios from 1.8.3 to 1.8.4 Snyk has created this PR to upgrade axios from 1.8.3 to 1.8.4. See this package in npm: axios See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 8 ++++---- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index 210d7dc54..5309b6ff3 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", - "axios": "^1.8.3", + "axios": "^1.8.4", "core-js": "^3.42.0", "cronstrue": "^2.56.0", "highlight.js": "^11.11.1", @@ -3776,9 +3776,9 @@ } }, "node_modules/axios": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", - "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", + "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index ca9bf3934..5d0e6a207 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", - "axios": "^1.8.3", + "axios": "^1.8.4", "core-js": "^3.42.0", "cronstrue": "^2.56.0", "highlight.js": "^11.11.1", From ee17c6e35ed79483a17435ab956b8dc342ce1de2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:31:03 +0000 Subject: [PATCH 02/18] Bump http-proxy-middleware in /platypush/backend/http/webapp Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.6 to 2.0.9. - [Release notes](https://github.com/chimurai/http-proxy-middleware/releases) - [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md) - [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.6...v2.0.9) --- updated-dependencies: - dependency-name: http-proxy-middleware dependency-version: 2.0.9 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> --- platypush/backend/http/webapp/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index 5309b6ff3..6a0fc9d43 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -7243,10 +7243,11 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", + "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", "dev": true, + "license": "MIT", "dependencies": { "@types/http-proxy": "^1.17.8", "http-proxy": "^1.18.1", From d0416ca571502718e0aeaeeae802e033da898007 Mon Sep 17 00:00:00 2001 From: snyk-bot <snyk-bot@snyk.io> Date: Wed, 23 Apr 2025 07:11:00 +0000 Subject: [PATCH 03/18] fix: upgrade cronstrue from 2.56.0 to 2.57.0 Snyk has created this PR to upgrade cronstrue from 2.56.0 to 2.57.0. See this package in npm: cronstrue See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 8 ++++---- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index 6a0fc9d43..2c1fd2214 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -11,7 +11,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "axios": "^1.8.4", "core-js": "^3.42.0", - "cronstrue": "^2.56.0", + "cronstrue": "^2.57.0", "highlight.js": "^11.11.1", "lato-font": "^3.0.0", "mitt": "^2.1.0", @@ -4712,9 +4712,9 @@ } }, "node_modules/cronstrue": { - "version": "2.56.0", - "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.56.0.tgz", - "integrity": "sha512-/YC3b4D/E/S8ToQ7f676A2fqoC3vVpXKjJ4SMsP0jYsvRYJdZ6h9+Fq/Y7FoFDEUFCqLTca+G2qTV227lyyFZg==", + "version": "2.57.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.57.0.tgz", + "integrity": "sha512-gQOfxJa1RA9uDT4hx37NshhX4dW9t9zTCtIYu15LUziH+mkpuLXYcSEyM8ZewMJ2p8UVuHGjI3n4hGpu3HtCbg==", "license": "MIT", "bin": { "cronstrue": "bin/cli.js" diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 5d0e6a207..6ee3d4315 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -11,7 +11,7 @@ "@fortawesome/fontawesome-free": "^6.7.2", "axios": "^1.8.4", "core-js": "^3.42.0", - "cronstrue": "^2.56.0", + "cronstrue": "^2.57.0", "highlight.js": "^11.11.1", "lato-font": "^3.0.0", "mitt": "^2.1.0", From b97fed4ffe8a4182fce6e8cc58f7ca404e6b6aa2 Mon Sep 17 00:00:00 2001 From: snyk-bot <snyk-bot@snyk.io> Date: Wed, 21 May 2025 09:17:26 +0000 Subject: [PATCH 04/18] fix: upgrade vue-router from 4.5.0 to 4.5.1 Snyk has created this PR to upgrade vue-router from 4.5.0 to 4.5.1. See this package in npm: vue-router See this project in Snyk: https://app.snyk.io/org/blacklight/project/96bfd125-5816-4d9e-83c6-94d1569ab0f1?utm_source=github&utm_medium=referral&page=upgrade-pr --- platypush/backend/http/webapp/package-lock.json | 8 ++++---- platypush/backend/http/webapp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/platypush/backend/http/webapp/package-lock.json b/platypush/backend/http/webapp/package-lock.json index 2c1fd2214..d0e2c27d9 100644 --- a/platypush/backend/http/webapp/package-lock.json +++ b/platypush/backend/http/webapp/package-lock.json @@ -19,7 +19,7 @@ "sass": "^1.77.6", "sass-loader": "^10.5.2", "vue": "^3.5.13", - "vue-router": "^4.5.0", + "vue-router": "^4.5.1", "vue-skycons": "^4.3.4", "w3css": "^2.7.0" }, @@ -12313,9 +12313,9 @@ } }, "node_modules/vue-router": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz", - "integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==", + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", + "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", "license": "MIT", "dependencies": { "@vue/devtools-api": "^6.6.4" diff --git a/platypush/backend/http/webapp/package.json b/platypush/backend/http/webapp/package.json index 6ee3d4315..8cd833494 100644 --- a/platypush/backend/http/webapp/package.json +++ b/platypush/backend/http/webapp/package.json @@ -19,7 +19,7 @@ "sass": "^1.77.6", "sass-loader": "^10.5.2", "vue": "^3.5.13", - "vue-router": "^4.5.0", + "vue-router": "^4.5.1", "vue-skycons": "^4.3.4", "w3css": "^2.7.0" }, From 9d8f4bbcadc0533dd2a9cf0226a42db08a7e99c4 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sat, 10 May 2025 21:31:40 +0200 Subject: [PATCH 05/18] Added stack trace in debug mode when a message fails to be serialized --- platypush/message/__init__.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/platypush/message/__init__.py b/platypush/message/__init__.py index a1f295ff0..615bce232 100644 --- a/platypush/message/__init__.py +++ b/platypush/message/__init__.py @@ -8,12 +8,14 @@ import logging import inspect import json import time +import traceback as tb from typing import Optional, Union from uuid import UUID _logger = logging.getLogger('platypush') +# pylint: disable=too-few-public-methods class JSONAble(ABC): """ Generic interface for JSON-able objects. @@ -38,6 +40,7 @@ class Message: """ @staticmethod + # pylint: disable=too-many-return-statements def parse_numpy(obj): try: import numpy as np @@ -57,13 +60,16 @@ class Message: except (AttributeError, ImportError, TypeError): pass - return + return None @staticmethod def parse_datetime(obj): if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)): return obj.isoformat() + return None + + # pylint: disable=too-many-return-statements def default(self, o): from platypush.procedure import Procedure @@ -94,7 +100,7 @@ class Message: return f'<{o.__class__.__name__}>' + (f' {o}' if o else '') if is_dataclass(o): - return asdict(o) + return asdict(o) # type: ignore if isinstance(o, Message): return o.to_dict(o) @@ -106,10 +112,20 @@ class Message: try: return super().default(o) except Exception as e: + _logger.debug( + 'Could not serialize object type %s: %s: %s\n%s', + type(o), + e, + o, + tb.format_exc(), + ) + _logger.warning( 'Could not serialize object type %s: %s: %s', type(o), e, o ) + return None + def __init__(self, *_, timestamp=None, logging_level=logging.INFO, **__): self.timestamp = timestamp or time.time() self.logging_level = logging_level @@ -212,5 +228,7 @@ class Message: if msgtype != cls: return msgtype.build(msg) + return None + # vim:sw=4:ts=4:et: From e67f5016158a64f748fe73b877b32890df3fb057 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Wed, 18 Jun 2025 22:33:53 +0200 Subject: [PATCH 06/18] [#449] Added Joplin integration + general-purpose template for note plugins --- platypush/common/notes.py | 143 ++++ platypush/message/event/notes.py | 120 ++++ platypush/plugins/_notes.py | 932 +++++++++++++++++++++++++ platypush/plugins/joplin/__init__.py | 441 ++++++++++++ platypush/plugins/joplin/manifest.json | 17 + platypush/schemas/notes.py | 237 +++++++ 6 files changed, 1890 insertions(+) create mode 100644 platypush/common/notes.py create mode 100644 platypush/message/event/notes.py create mode 100644 platypush/plugins/_notes.py create mode 100644 platypush/plugins/joplin/__init__.py create mode 100644 platypush/plugins/joplin/manifest.json create mode 100644 platypush/schemas/notes.py diff --git a/platypush/common/notes.py b/platypush/common/notes.py new file mode 100644 index 000000000..1e8a5da0c --- /dev/null +++ b/platypush/common/notes.py @@ -0,0 +1,143 @@ +from abc import abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from hashlib import sha256 +from typing import Any, Dict, List, Optional, Set + +from platypush.message import JSONAble +from platypush.schemas.notes import NoteCollectionSchema, NoteItemSchema + + +class Serializable(JSONAble): + """ + Base class for serializable objects. + """ + + @abstractmethod + def to_dict(self) -> dict: + """ + Convert the object to a dictionary representation. + """ + + def to_json(self) -> dict: + return self.to_dict() + + +@dataclass +class NoteSource(Serializable): + """ + Represents a source for a note, such as a URL or file path. + """ + + name: Optional[str] = None + url: Optional[str] = None + app: Optional[str] = None + + def to_dict(self) -> dict: + return self.__dict__ + + +@dataclass +class Note(Serializable): + """ + Represents a note with a title and content. + """ + + id: Any + title: str + description: Optional[str] = None + content: Optional[str] = None + parent: Optional['NoteCollection'] = None + tags: Set[str] = field(default_factory=set) + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + digest: Optional[str] = field(default=None) + latitude: Optional[float] = None + longitude: Optional[float] = None + altitude: Optional[float] = None + author: Optional[str] = None + source: Optional[NoteSource] = None + + def __post_init__(self): + """ + Post-initialization to update the digest if content is provided. + """ + self.digest = self._update_digest() + + def _update_digest(self) -> Optional[str]: + if self.content and not self.digest: + self.digest = sha256(self.content.encode('utf-8')).hexdigest() + return self.digest + + def to_dict(self) -> dict: + return NoteItemSchema().dump( # type: ignore + { + **{ + field: getattr(self, field) + for field in self.__dataclass_fields__ + if not field.startswith('_') and field != 'parent' + }, + 'parent': ( + { + 'id': self.parent.id if self.parent else None, + 'title': self.parent.title if self.parent else None, + } + if self.parent + else None + ), + }, + ) + + +@dataclass +class NoteCollection(Serializable): + """ + Represents a collection of notes. + """ + + id: Any + title: str + description: Optional[str] = None + parent: Optional['NoteCollection'] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + _notes: Dict[Any, Note] = field(default_factory=dict) + _collections: Dict[Any, 'NoteCollection'] = field(default_factory=dict) + + @property + def notes(self) -> List[Note]: + return list(self._notes.values()) + + @property + def collections(self) -> List['NoteCollection']: + return list(self._collections.values()) + + def to_dict(self) -> dict: + return NoteCollectionSchema().dump( # type: ignore + { + **{ + field: getattr(self, field) + for field in self.__dataclass_fields__ + if not field.startswith('_') and field != 'parent' + }, + 'parent': ( + { + 'id': self.parent.id, + 'title': self.parent.title, + } + if self.parent + else None + ), + 'collections': [ + collection.to_dict() for collection in self.collections + ], + 'notes': [ + { + field: getattr(note, field) + for field in [*note.__dataclass_fields__, 'digest'] + if field not in ['parent', 'content'] + } + for note in self.notes + ], + } + ) diff --git a/platypush/message/event/notes.py b/platypush/message/event/notes.py new file mode 100644 index 000000000..db9ead988 --- /dev/null +++ b/platypush/message/event/notes.py @@ -0,0 +1,120 @@ +import datetime +from abc import ABC +from typing import Optional, Union + +from dateutil.parser import isoparse + +from platypush.common.notes import Note, NoteCollection +from platypush.message.event import Event + +DateLike = Optional[Union[float, str, datetime.datetime]] + + +class BaseNoteEvent(Event, ABC): + """ + Base class for note events. + """ + + def __init__( + self, + *args, + plugin: str, + **kwargs, + ): + """ + :param plugin: The name of the plugin that triggered the event. + """ + super().__init__(*args, plugin=plugin, **kwargs) + + def _parse_timestamp( + self, timestamp: DateLike = None + ) -> Optional[datetime.datetime]: + """ + Parse a timestamp string into a datetime object. + """ + if timestamp is None: + return None + + if isinstance(timestamp, datetime.datetime): + return timestamp + + if isinstance(timestamp, (int, float)): + return datetime.datetime.fromtimestamp(timestamp) + + try: + return isoparse(timestamp) + except ValueError: + return None + + +class NoteItemEvent(BaseNoteEvent, ABC): + """ + Base class for note item events. + """ + + def __init__( # pylint: disable=useless-parent-delegation + self, *args, note: Note, **kwargs + ): + """ + :param note: Format: + + .. schema:: notes.NoteItemSchema + + """ + super().__init__(*args, note=note, **kwargs) + + +class NoteCollectionEvent(BaseNoteEvent, ABC): + """ + Base class for note collection events. + """ + + def __init__( # pylint: disable=useless-parent-delegation + self, *args, collection: NoteCollection, **kwargs + ): + """ + :param collection: Format: + + .. schema:: notes.NoteCollectionSchema + + """ + super().__init__(*args, collection=collection, **kwargs) + + +class NoteCreatedEvent(NoteItemEvent): + """ + Event triggered when a note is created + """ + + +class NoteUpdatedEvent(NoteItemEvent): + """ + Event triggered when a note is updated. + """ + + +class NoteDeletedEvent(NoteItemEvent): + """ + Event triggered when a note is deleted. + """ + + +class CollectionCreatedEvent(NoteCollectionEvent): + """ + Event triggered when a note collection is created. + """ + + +class CollectionUpdatedEvent(NoteCollectionEvent): + """ + Event triggered when a note collection is updated. + """ + + +class CollectionDeletedEvent(NoteCollectionEvent): + """ + Event triggered when a note collection is deleted. + """ + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/_notes.py b/platypush/plugins/_notes.py new file mode 100644 index 000000000..2b8682cc1 --- /dev/null +++ b/platypush/plugins/_notes.py @@ -0,0 +1,932 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from threading import RLock +from time import time +from typing import Any, Dict, Iterable, Optional, Type + +from platypush.common.notes import Note, NoteCollection, NoteSource +from platypush.context import Variable +from platypush.message.event.notes import ( + BaseNoteEvent, + NoteCreatedEvent, + NoteUpdatedEvent, + NoteDeletedEvent, + CollectionCreatedEvent, + CollectionUpdatedEvent, + CollectionDeletedEvent, +) +from platypush.plugins import RunnablePlugin, action +from platypush.utils import get_plugin_name_by_class + + +class BaseNotePlugin(RunnablePlugin, ABC): + """ + Base class for note-taking plugins. + """ + + def __init__(self, *args, poll_interval: float = 300, **kwargs): + """ + :param poll_interval: Poll interval in seconds to check for updates (default: 300). + If set to zero or null, the plugin will not poll for updates, + and events will be generated only when you manually call :meth:`.sync`. + """ + super().__init__(*args, poll_interval=poll_interval, **kwargs) + self._notes: Dict[Any, Note] = {} + self._collections: Dict[Any, NoteCollection] = {} + self._notes_lock = RLock() + self._collections_lock = RLock() + self._sync_lock = RLock() + self.__last_sync_time: Optional[datetime] = None + + @property + def _last_sync_time_var(self) -> Variable: + """ + Variable name for the last sync time. + """ + return Variable( + f'_LAST_ITEMS_SYNC_TIME[{get_plugin_name_by_class(self.__class__)}]' + ) + + @property + def _last_sync_time(self) -> Optional[datetime]: + """ + Get the last sync time from the variable. + """ + if not self.__last_sync_time: + t = self._last_sync_time_var.get() + self.__last_sync_time = datetime.fromtimestamp(float(t)) if t else None + + return self.__last_sync_time + + @_last_sync_time.setter + def _last_sync_time(self, value: Optional[datetime]): + """ + Set the last sync time to the variable. + """ + with self._sync_lock: + self.__last_sync_time = value + if value is None: + self._last_sync_time_var.set(None) + else: + self._last_sync_time_var.set(value.timestamp()) + + @abstractmethod + def _fetch_note(self, note_id: Any, *args, **kwargs) -> Optional[Note]: + """ + Don't call this directly if possible. + Instead, use :meth:`.get_note` method to retrieve a note and update the cache + in a thread-safe manner. + + :param note_id: The ID of the note to fetch. + :return: The latest note from the backend. + """ + + @abstractmethod + def _fetch_notes(self, *args, **kwargs) -> Iterable[Note]: + """ + Don't call this directly if possible. + Instead, use :meth:`.get_notes` method to retrieve notes and update the cache + in a thread-safe manner. + + :return: The latest list of notes from the backend. + """ + + @abstractmethod + def _create_note( + self, + title: str, + content: str, + *args, + description: Optional[str] = None, + collection: Optional[Any] = None, + geo: Optional[Dict[str, Optional[float]]] = None, + author: Optional[str] = None, + source: Optional[NoteSource] = None, + **kwargs, + ) -> Note: + """ + Create a new note with the specified title and content. + """ + + @abstractmethod + def _edit_note( + self, + note_id: Any, + *args, + title: Optional[str] = None, + content: Optional[str] = None, + collection: Optional[Any] = None, + geo: Optional[Dict[str, Optional[float]]] = None, + author: Optional[str] = None, + source: Optional[NoteSource] = None, + **kwargs, + ) -> Note: + """ + Edit an existing note by ID. + """ + + @abstractmethod + def _delete_note(self, note_id: Any, *args, **kwargs): + """ + Delete a note by ID. + """ + + @abstractmethod + def _fetch_collection( + self, collection_id: Any, *args, **kwargs + ) -> Optional[NoteCollection]: + """ + Don't call this directly if possible. + Instead, use :meth:`.get_collection` to retrieve a collection and update the cache + in a thread-safe manner. + + :param collection_id: The ID of the collection to fetch. + :return: The latest collection from the backend. + """ + + @abstractmethod + def _fetch_collections(self, *args, **kwargs) -> Iterable[NoteCollection]: + """ + Don't call this directly if possible. + Instead, use :meth:`.get_collections` to retrieve collections and update the cache + in a thread-safe manner. + + :return: The latest list of note collections from the backend. + """ + + @abstractmethod + def _create_collection( + self, + title: str, + *args, + description: Optional[str] = None, + parent: Optional[Any] = None, + **kwargs, + ) -> NoteCollection: + """ + Create a new note collection with the specified title and description. + """ + + @abstractmethod + def _edit_collection( + self, + collection_id: Any, + *args, + title: Optional[str] = None, + description: Optional[str] = None, + parent: Optional[Any] = None, + **kwargs, + ) -> NoteCollection: + """ + Edit an existing note collection by ID. + """ + + @abstractmethod + def _delete_collection(self, collection_id: Any, *args, **kwargs): + """ + Delete a note collection by ID. + This method should be implemented by subclasses. + """ + + def _process_results( # pylint: disable=too-many-positional-arguments + self, + items: Iterable[Any], + limit: Optional[int] = None, + offset: Optional[int] = None, + sort: Optional[Dict[str, bool]] = None, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + ) -> Iterable[Any]: + if not sort: + sort = {'created_at': True} + + if filter: + items = [ + item + for item in items + if all(getattr(item, k) == v for k, v in filter.items()) + ] + + items = sorted( + items, + key=lambda item: tuple(getattr(item, field) for field in sort.keys()), + reverse=any(not ascending for ascending in sort.values()), + ) + + if offset is not None: + items = items[offset:] + if limit is not None: + items = items[:limit] + + return items + + def _dispatch_events(self, *events): + """ + Dispatch the given events to the event bus. + """ + if not self.bus: + self.logger.warning( + 'Event bus not available. Events will not be dispatched.' + ) + return + + for event in events: + self.bus.post(event) + + def _process_events( + self, + notes: Iterable[Note], + prev_notes: Dict[Any, Note], + collections: Iterable[NoteCollection], + prev_collections: Dict[Any, NoteCollection], + ): + most_recent_note = list(notes)[0] if notes else None + most_recent_collection = list(collections)[0] if collections else None + max_updated_at = max( + ( + most_recent_note.updated_at.timestamp() + if most_recent_note and most_recent_note.updated_at + else 0 + ), + ( + most_recent_collection.updated_at.timestamp() + if most_recent_collection and most_recent_collection.updated_at + else 0 + ), + ) + + with self._sync_lock: + self._process_collections_events(collections, prev_collections) + self._process_notes_events(notes, prev_notes) + self._last_sync_time = ( + datetime.fromtimestamp(max_updated_at) if max_updated_at > 0 else None + ) + + @classmethod + def _make_event( + cls, evt_type: Type[BaseNoteEvent], *args, **kwargs + ) -> BaseNoteEvent: + """ + Create a note event of the specified type. + """ + return evt_type(*args, plugin=get_plugin_name_by_class(cls), **kwargs) + + def _process_notes_events( + self, + notes: Iterable[Note], + prev_notes: Dict[Any, Note], + ): + last_sync_time = self._last_sync_time.timestamp() if self._last_sync_time else 0 + new_notes_by_id = {note.id: note for note in notes} + + removed_note_events = [ + self._make_event(NoteDeletedEvent, note=note) + for note_id, note in prev_notes.items() + if note_id not in new_notes_by_id + ] + + created_note_events = [] + updated_note_events = [] + + for note in notes: + created_at = note.created_at.timestamp() if note.created_at else 0 + updated_at = note.updated_at.timestamp() if note.updated_at else 0 + + if created_at > last_sync_time: + created_note_events.append( + self._make_event(NoteCreatedEvent, note=note) + ) + elif updated_at > last_sync_time: + updated_note_events.append( + self._make_event(NoteUpdatedEvent, note=note) + ) + else: + # Assuming that the list of notes is sorted by updated_at + break + + self._dispatch_events( + *removed_note_events, + *created_note_events, + *updated_note_events, + ) + + def _process_collections_events( + self, + collections: Iterable[NoteCollection], + prev_collections: Dict[Any, NoteCollection], + ): + last_sync_time = self._last_sync_time.timestamp() if self._last_sync_time else 0 + new_collections_by_id = { + collection.id: collection for collection in collections + } + + removed_collection_events = [ + self._make_event(CollectionDeletedEvent, collection=collection) + for collection_id, collection in prev_collections.items() + if collection_id not in new_collections_by_id + ] + + created_collection_events = [] + updated_collection_events = [] + + for collection in collections: + created_at = ( + collection.created_at.timestamp() if collection.created_at else 0 + ) + updated_at = ( + collection.updated_at.timestamp() if collection.updated_at else 0 + ) + + if created_at > last_sync_time: + created_collection_events.append( + self._make_event(CollectionCreatedEvent, collection=collection) + ) + elif updated_at > last_sync_time: + updated_collection_events.append( + self._make_event(CollectionUpdatedEvent, collection=collection) + ) + else: + # Assuming that the list of collections is sorted by updated_at + break + + self._dispatch_events( + *removed_collection_events, + *created_collection_events, + *updated_collection_events, + ) + + def _get_note(self, note_id: Any, *args, **kwargs) -> Note: + note = self._fetch_note(note_id, *args, **kwargs) + assert note, f'Note with ID {note_id} not found' + with self._notes_lock: + # Always overwrite the note in the cache, + # as this is the most up-to-date complete version + self._notes[note.id] = note + if note.parent and note.parent.id in self._collections: + self._collections[ # pylint: disable=protected-access + note.parent.id + ]._notes[ # pylint: disable=protected-access + note.id + ] = note + + return self._notes[note.id] + + def _get_notes( + self, + *args, + limit: Optional[int] = None, + offset: Optional[int] = None, + sort: Optional[Dict[str, bool]] = None, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + fetch: bool = False, + **kwargs, + ) -> Iterable[Note]: + # Always fetch if background polling is disabled + fetch = fetch or not self.poll_interval + if fetch: + with self._notes_lock: + self._notes = { + note.id: note for note in self._fetch_notes(*args, **kwargs) + } + self._refresh_notes_cache() + + return self._process_results( + self._notes.values(), + limit=limit, + offset=offset, + sort=sort, + filter=filter, + ) + + def _get_collection(self, collection_id: Any, *args, **kwargs) -> NoteCollection: + collection = self._fetch_collection(collection_id, *args, **kwargs) + assert collection, f'Collection with ID {collection_id} not found' + with self._collections_lock: + # Always overwrite the collection in the cache, + # as this is the most up-to-date complete version + self._collections[collection.id] = collection + self._refresh_collections_cache() + + return self._collections[collection.id] + + def _get_collections( + self, + *args, + limit: Optional[int] = None, + offset: Optional[int] = None, + sort: Optional[Dict[str, bool]] = None, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + fetch: bool = False, + **kwargs, + ) -> Iterable[NoteCollection]: + """ + Get the note collections from the cache or fetch them from the backend if needed. + + :return: An iterable of NoteCollection objects. + """ + # Always fetch if background polling is disabled + fetch = fetch or not self.poll_interval + if fetch: + with self._collections_lock: + self._collections = { + collection.id: collection + for collection in self._fetch_collections(*args, **kwargs) + } + self._refresh_collections_cache() + + return self._process_results( + self._collections.values(), + limit=limit, + offset=offset, + sort=sort, + filter=filter, + ) + + def _refresh_notes_cache(self): + for note in list(self._notes.values()): + if note.parent and note.parent.id in self._collections: + note.parent = self._collections[note.parent.id] + self._collections[ # pylint: disable=protected-access + note.parent.id + ]._notes[ # pylint: disable=protected-access + note.id + ] = note + + def _refresh_collections_cache(self): + for collection in list(self._collections.values()): + # Link the notes to their parent collections + for note in list(collection.notes): + if note.id in self._notes: + collection._notes[note.id] = self._notes[ + note.id + ] # pylint: disable=protected-access + + # Link the child collections to their parent collections + tmp_collections = list(collection.collections) + for collection in tmp_collections: + if collection.id not in self._collections: + collection._collections[ + collection.id + ] = self._collections[ # pylint: disable=protected-access + collection.id + ] + + # Link the parent collections to their child collections + tmp_collections = list(collection.collections) + for collection in tmp_collections: + if collection.parent and collection.parent.id in self._collections: + collection.parent = self._collections[collection.parent.id] + + @staticmethod + def _parse_geo(data: dict) -> Dict[str, Optional[float]]: + return { + key: value or None + for key, value in { + key: float(data.get(key, 0)) + for key in ('latitude', 'longitude', 'altitude') + }.items() + } + + @action + def get_note(self, note_id: Any, *args, **kwargs) -> dict: + """ + Get a single note and its full content by ID. + + :param note_id: The ID of the note to retrieve. + :return: The note as a dictionary, format: + + .. schema:: notes.NoteItemSchema + + """ + return self._get_note(note_id, *args, **kwargs).to_dict() + + @action + def get_notes( + self, + *args, + limit: Optional[int] = None, + offset: Optional[int] = 0, + sort: Optional[Dict[str, bool]] = None, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + fetch: bool = False, + **kwargs, + ) -> Iterable[dict]: + """ + Get notes from the cache or fetch them from the backend. + + :param limit: Maximum number of collections to retrieve (default: None, meaning no limit). + :param offset: Offset to start retrieving collections from (default: 0). + :param sort: A dictionary specifying the fields to sort by and their order. + Example: {'created_at': True} sorts by creation date in ascending + order, while {'created_at': False} sorts in descending order. + :param filter: A dictionary specifying filters to apply to the collections. + :param fetch: If True, always fetch the latest collections from the backend, + regardless of the cache state (default: False). + :param kwargs: Additional keyword arguments to pass to the fetch method. + :return: An iterable of notes, format: + + .. schema:: notes.NoteItemSchema(many=True) + + """ + return [ + note.to_dict() + for note in self._get_notes( + *args, + limit=limit, + offset=offset, + sort=sort, + filter=filter, + fetch=fetch, + **kwargs, + ) + ] + + @action + def create_note( + self, + title: str, + content: str, + *args, + description: Optional[str] = None, + collection: Optional[Any] = None, + geo: Optional[Dict[str, Optional[float]]] = None, + source: Optional[dict] = None, + author: Optional[str] = None, + **kwargs, + ) -> dict: + """ + Create a new note with the specified title and content. + + :param title: The title of the new note. + :param content: The content of the new note. + :param description: Optional description for the note. + :param collection: Optional collection ID to add the note to. + :param geo: Optional geographical coordinates as a dictionary with the + fields ``latitude``, ``longitude``, and ``altitude``. + :param source: Optional source information for the note, with at least + one of the fields ``url``, ``name`` or ``app``. By default, the + source is ``platypush`` and the app is ``tech.platypush``. + :param author: Optional author of the note. + :return: The created note as a dictionary, format: + + .. schema:: notes.NoteItemSchema + """ + src = NoteSource( + **( + source + or { + 'name': 'platypush', + 'app': 'tech.platypush', + } + ) + ) + + note = self._create_note( + title, + content, + *args, + description=description, + collection=collection, + geo=self._parse_geo(geo) if geo else None, + author=author, + source=src, + **kwargs, + ) + + with self._notes_lock: + # Add the new note to the cache + self._notes[note.id] = note + + # If it has a parent collection, add it to the collection's notes + if note.parent and note.parent.id in self._collections: + self._collections[ # pylint: disable=protected-access + note.parent.id + ]._notes[note.id] = note + + # Trigger the note created event + self._dispatch_events(self._make_event(NoteCreatedEvent, note=note)) + + return note.to_dict() + + @action + def edit_note( + self, + note_id: Any, + *args, + title: Optional[str] = None, + content: Optional[str] = None, + description: Optional[str] = None, + collection: Optional[Any] = None, + geo: Optional[Dict[str, Optional[float]]] = None, + source: Optional[dict] = None, + author: Optional[str] = None, + **kwargs, + ) -> dict: + """ + Edit an existing note by ID. + + :param note_id: The ID of the note to edit. + :param title: New title for the note. + :param content: New content for the note. + :param description: Optional new description for the note. + :param collection: New collection ID to move the note under. + :param geo: Optional geographical coordinates as a dictionary with the + fields ``latitude``, ``longitude``, and ``altitude``. + :param source: Optional source information for the note, with at least + one of the fields ``url``, ``name`` or ``app``. + :param author: Optional author of the note. + :return: The updated note as a dictionary, format: + + .. schema:: notes.NoteItemSchema + """ + with self._notes_lock: + note = self._edit_note( + note_id, + *args, + title=title, + content=content, + description=description, + collection=collection, + geo=self._parse_geo(geo) if geo else None, + author=author, + source=NoteSource(**source) if source else None, + **kwargs, + ) + + # Update the cache with the edited note + self._notes[note.id] = note + + # If it has a parent collection, update it in the collection's notes + if note.parent and note.parent.id in self._collections: + self._collections[ # pylint: disable=protected-access + note.parent.id + ]._notes[ # pylint: disable=protected-access + note.id + ] = note + + # Trigger the note updated event + self._dispatch_events(self._make_event(NoteUpdatedEvent, note=note)) + + return note.to_dict() + + @action + def delete_note(self, note_id: Any, *args, **kwargs): + """ + Delete a note by ID. + + :param note_id: The ID of the note to delete. + """ + with self._notes_lock: + self._delete_note(note_id, *args, **kwargs) + note = self._notes.pop(note_id, None) + if not note: + note = Note(id=note_id, title='') + + # Remove the note from its parent collection if it has one + if note.parent and note.parent.id in self._collections: + parent_collection = self._collections[note.parent.id] + parent_collection.notes.remove(note) + + # Dispatch the deletion event + self._dispatch_events(self._make_event(NoteDeletedEvent, note=note)) + + @action + def get_collection(self, collection_id: Any, *args, **kwargs) -> dict: + """ + Get a single note collection by ID. + + :param collection_id: The ID of the collection to retrieve. + :return: The collection as a dictionary, format: + + .. schema:: notes.NoteCollectionSchema + + """ + return self._get_collection(collection_id, *args, **kwargs).to_dict() + + @action + def get_collections( + self, + *args, + limit: Optional[int] = None, + offset: Optional[int] = 0, + sort: Optional[Dict[str, bool]] = None, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + fetch: bool = False, + **kwargs, + ) -> Iterable[dict]: + """ + Get note collections from the cache or fetch them from the backend. + + :param limit: Maximum number of collections to retrieve (default: None, meaning no limit). + :param offset: Offset to start retrieving collections from (default: 0). + :param sort: A dictionary specifying the fields to sort by and their order. + Example: {'created_at': True} sorts by creation date in ascending + order, while {'created_at': False} sorts in descending order. + :param filter: A dictionary specifying filters to apply to the collections. + :param fetch: If True, always fetch the latest collections from the backend, + regardless of the cache state (default: False). + :param kwargs: Additional keyword arguments to pass to the fetch method. + :return: An iterable of note collections, format: + + .. schema:: notes.NoteCollectionSchema(many=True) + + """ + return [ + collection.to_dict() + for collection in self._get_collections( + *args, + limit=limit, + offset=offset, + sort=sort, + filter=filter, + fetch=fetch, + **kwargs, + ) + ] + + @action + def create_collection( + self, + title: str, + *args, + description: Optional[str] = None, + parent: Optional[Any] = None, + **kwargs, + ) -> dict: + """ + Create a new note collection with the specified title and description. + + :param title: The title of the new collection. + :param description: Optional description for the new collection. + :param parent: Optional parent collection ID to create a sub-collection. + :return: The created collection as a dictionary, format: + + .. schema:: notes.NoteCollectionSchema + + """ + collection = self._create_collection( + title, *args, description=description, parent=parent, **kwargs + ) + + with self._collections_lock: + # Add the new collection to the cache + self._collections[collection.id] = collection + + # If it has a parent, add it to the parent's collections + if collection.parent and collection.parent.id in self._collections: + parent_collection = self._collections[collection.parent.id] + parent_collection.collections.append(collection) + + # Trigger the collection created event + self._dispatch_events( + self._make_event(CollectionCreatedEvent, collection=collection) + ) + + return collection.to_dict() + + @action + def edit_collection( + self, + collection_id: Any, + *args, + title: Optional[str] = None, + description: Optional[str] = None, + parent: Optional[Any] = None, + **kwargs, + ) -> dict: + """ + Edit an existing note collection by ID. + + :param collection_id: The ID of the collection to edit. + :param title: New title for the collection. + :param description: New description for the collection. + :param parent: New parent collection ID to move the collection under. + :return: The updated collection as a dictionary, format: + + .. schema:: notes.NoteCollectionSchema + + """ + with self._collections_lock: + collection = self._edit_collection( + collection_id, + *args, + title=title, + description=description, + parent=parent, + **kwargs, + ) + + # Update the cache with the edited collection + old_collection = self._collections.get(collection.id) + self._collections[collection.id] = collection + + # If the parent has changed, remove it from the old parent's collections + if ( + old_collection + and old_collection.parent + and old_collection.parent != collection.parent + and old_collection.parent.id in self._collections + ): + parent_collection = self._collections.get(old_collection.parent.id) + if ( + parent_collection + and old_collection in parent_collection.collections + ): + parent_collection.collections.remove(old_collection) + + # If it has a parent, update it in the parent's collections + if collection.parent and collection.parent.id in self._collections: + parent_collection = self._collections.get(collection.parent.id) + if ( + parent_collection + and collection not in parent_collection.collections + ): + parent_collection.collections.append(collection) + + # Trigger the collection updated event + self._dispatch_events( + self._make_event(CollectionUpdatedEvent, collection=collection) + ) + + return collection.to_dict() + + @action + def delete_collection(self, collection_id: Any, *args, **kwargs): + """ + Delete a note collection by ID. + + :param collection_id: The ID of the collection to delete. + """ + with self._collections_lock: + self._delete_collection(collection_id, *args, **kwargs) + collection = self._collections.pop(collection_id, None) + if not collection: + collection = NoteCollection(id=collection_id, title='') + + # Remove the collection from its parent if it has one + if collection.parent and collection.parent.id in self._collections: + parent_collection = self._collections[collection.parent.id] + parent_collection.collections.remove(collection) + + # Dispatch the deletion event + self._dispatch_events( + self._make_event(CollectionDeletedEvent, collection=collection) + ) + + @action + def sync(self, *args, **kwargs): + """ + Synchronize the notes and collections with the backend. + This method is called periodically based on the ``poll_interval`` setting. + If ``poll_interval`` is zero or null, you can manually call this method + to synchronize the notes and collections. + """ + t_start = time() + self.logger.info('Synchronizing notes and collections...') + + with self._sync_lock: + prev_notes = self._notes.copy() + prev_collections = self._collections.copy() + notes = self._get_notes( + *args, fetch=True, sort={'updated_at': False}, **kwargs + ) + collections = self._get_collections( + *args, fetch=True, sort={'updated_at': False}, **kwargs + ) + + self._process_events( + notes=notes, + prev_notes=prev_notes, + collections=collections, + prev_collections=prev_collections, + ) + + self.logger.info( + 'Synchronization completed in %.2f seconds', + time() - t_start, + ) + + @action + def reset_sync(self): + """ + Reset the last sync time to None, forcing a full resync on the next call to + :meth:`.sync`, which in turn will re-trigger all notes and collections events. + """ + self.logger.info('Resetting last sync time') + with self._sync_lock: + self._last_sync_time = None + self._notes.clear() + self._collections.clear() + + def main(self): + if not self.poll_interval: + # If no poll interval is set then we won't poll for new check-ins + self.wait_stop() + return + + while not self.should_stop(): + try: + self.sync() + except Exception as e: + self.logger.error('Error during sync: %s', e) + finally: + self.wait_stop(self.poll_interval) diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py new file mode 100644 index 000000000..61e6e05d6 --- /dev/null +++ b/platypush/plugins/joplin/__init__.py @@ -0,0 +1,441 @@ +import datetime +from typing import Any, List, Optional +from urllib.parse import urljoin + +import requests + +from platypush.common.notes import Note, NoteCollection, NoteSource +from platypush.plugins._notes import BaseNotePlugin + + +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. + """ + + _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', + ) + + 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=10, **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.datetime]: + """ + Parse a Joplin timestamp (in milliseconds) into a datetime object. + """ + if t is None: + return None + return datetime.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, title='') + ) + + return Note( + **{ + 'id': data.get('id', ''), + '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', ''), + 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 _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, *_, **__) -> List[Note]: + """ + Fetch notes from Joplin. + """ + notes_data = ( + self._exec( + 'GET', 'notes', params={'fields': ','.join(self._default_note_fields)} + ) + or {} + ).get('items', []) + return [self._to_note(note) for note in notes_data] + + 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, *_, **__) -> List[NoteCollection]: + """ + Fetch collections (folders) from Joplin. + """ + collections_data = ( + self._exec( + 'GET', + 'folders', + params={'fields': ','.join(self._default_collection_fields)}, + ) + 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}') + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/joplin/manifest.json b/platypush/plugins/joplin/manifest.json new file mode 100644 index 000000000..cf4e652d3 --- /dev/null +++ b/platypush/plugins/joplin/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest": { + "events": [ + "platypush.message.event.notes.NoteCreatedEvent", + "platypush.message.event.notes.NoteUpdatedEvent", + "platypush.message.event.notes.NoteDeletedEvent", + "platypush.message.event.notes.CollectionCreatedEvent", + "platypush.message.event.notes.CollectionUpdatedEvent", + "platypush.message.event.notes.CollectionDeletedEvent" + ], + "install": { + "pip": [] + }, + "package": "platypush.plugins.joplin", + "type": "plugin" + } +} diff --git a/platypush/schemas/notes.py b/platypush/schemas/notes.py new file mode 100644 index 000000000..5a4bc1324 --- /dev/null +++ b/platypush/schemas/notes.py @@ -0,0 +1,237 @@ +from abc import ABC +from marshmallow import INCLUDE, Schema, fields + +from platypush.schemas import DateTime + + +class BaseNoteSchema(Schema, ABC): + """ + Base schema for note objects. + """ + + # pylint: disable=too-few-public-methods + class Meta: # type: ignore + """ + Meta class for the schema. + """ + + unknown = INCLUDE + + +class NoteSourceSchema(BaseNoteSchema): + """ + Schema for a note source, such as a URL or file path. + """ + + name = fields.String( + metadata={ + 'description': 'Name of the source', + 'example': 'My Note Source', + }, + ) + + url = fields.String( + metadata={ + 'description': 'URL of the source', + 'example': 'https://example.com/note', + }, + ) + + app = fields.String( + metadata={ + 'description': 'Application associated with the source', + 'example': 'My Note App', + }, + ) + + +class NoteItemSchema(BaseNoteSchema): + """ + Schema for a note object. + """ + + id = fields.Raw( + required=True, + dump_only=True, + metadata={'example': 2345}, + ) + + title = fields.String( + metadata={ + 'description': 'Title of the note', + 'example': 'My Important Note', + }, + ) + + content = fields.String( + metadata={ + 'example': 'Note content goes here', + }, + ) + + description = fields.String( + metadata={ + 'description': 'Description of the note', + 'example': 'This note contains important information.', + }, + ) + + digest = fields.String( + dump_only=True, + metadata={ + 'description': 'Unique digest of the note content', + 'example': 'abc123def456', + }, + ) + + parent = fields.Nested( + 'NoteCollectionSchema', + dump_only=True, + metadata={ + 'description': 'Parent note collection', + 'example': { + 'id': 1234, + 'title': 'My Notes', + 'description': 'A collection of my important notes', + }, + }, + ) + + source = fields.Nested( + NoteSourceSchema, + metadata={ + 'description': 'Source of the note, such as a URL or file path', + 'example': { + 'name': 'My Note Source', + 'url': 'https://example.com/note', + 'app': 'My Note App', + }, + }, + ) + + author = fields.String( + metadata={ + 'description': 'Author of the note', + 'example': 'John Doe', + }, + ) + + latitude = fields.Float( + metadata={ + 'description': 'Latitude of the note location', + 'example': 37.7749, + }, + ) + + longitude = fields.Float( + metadata={ + 'description': 'Longitude of the note location', + 'example': -122.4194, + }, + ) + + altitude = fields.Float( + metadata={ + 'description': 'Altitude of the note location', + 'example': 30.0, + }, + ) + + tags = fields.List( + fields.String(), + metadata={ + 'description': 'List of tags associated with the note', + 'example': ['important', 'work'], + }, + ) + + created_at = DateTime( + metadata={ + 'description': 'When the note was created', + }, + ) + + updated_at = DateTime( + metadata={ + 'description': 'When the note was last updated', + }, + ) + + +class NoteCollectionSchema(BaseNoteSchema): + """ + Schema for a collection of notes. + """ + + id = fields.Raw( + dump_only=True, + metadata={'example': 1234}, + ) + + title = fields.String( + metadata={ + 'description': 'Title of the note collection', + 'example': 'My Notes', + }, + ) + + description = fields.String( + metadata={ + 'description': 'Description of the note collection', + 'example': 'A collection of my important notes', + }, + ) + + parent = fields.Nested( + 'NoteCollectionSchema', + metadata={ + 'description': 'Parent note collection', + 'example': { + 'id': 1233, + 'title': 'All Notes', + 'description': 'A top-level collection of all notes', + }, + }, + ) + + collections = fields.List( + fields.Nested('NoteCollectionSchema'), + metadata={ + 'description': 'List of sub-collections', + 'example': [ + { + 'id': 1235, + 'title': 'Work Notes', + 'description': 'Notes related to work projects', + }, + ], + }, + ) + + notes = fields.List( + fields.Nested(NoteItemSchema), + metadata={ + 'description': 'List of notes', + 'example': [ + { + 'id': 2345, + 'title': 'My Important Note', + 'content': 'Note content goes here', + 'created_at': '2023-10-01T12:00:00Z', + 'updated_at': '2023-10-02T12:00:00Z', + }, + ], + }, + ) + + created_at = DateTime( + metadata={ + 'description': 'When the note collection was created', + }, + ) + + updated_at = DateTime( + metadata={ + 'description': 'When the note collection was last updated', + }, + ) From f602d18099ca3c0b92fbedf8e0ed954c1eefd5d5 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Wed, 18 Jun 2025 22:35:45 +0200 Subject: [PATCH 07/18] Moved `plugins/_notes.py` to `plugins/_notes/__init__.py` --- platypush/plugins/{_notes.py => _notes/__init__.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename platypush/plugins/{_notes.py => _notes/__init__.py} (100%) diff --git a/platypush/plugins/_notes.py b/platypush/plugins/_notes/__init__.py similarity index 100% rename from platypush/plugins/_notes.py rename to platypush/plugins/_notes/__init__.py From 70fa5e4343ddf5da5049bf184066451628d26ba9 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Fri, 20 Jun 2025 16:28:28 +0200 Subject: [PATCH 08/18] [`alarm`] Synchronize with the entities engine before interacting with the db Otherwise the plugin may fail on startup if the database hasn't been initialized yet. --- platypush/plugins/alarm/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/platypush/plugins/alarm/__init__.py b/platypush/plugins/alarm/__init__.py index f98ebe53b..edacfb76f 100644 --- a/platypush/plugins/alarm/__init__.py +++ b/platypush/plugins/alarm/__init__.py @@ -6,7 +6,7 @@ from typing import Collection, Generator, Optional, Dict, Any, List, Union from sqlalchemy.orm import Session from platypush.context import get_plugin -from platypush.entities import EntityManager +from platypush.entities import EntityManager, get_entities_engine from platypush.entities.alarm import Alarm as DbAlarm from platypush.message.event.entities import EntityDeleteEvent from platypush.plugins import RunnablePlugin, action @@ -197,6 +197,9 @@ class AlarmPlugin(RunnablePlugin, EntityManager): alarm.stop() def _sync_alarms(self): + # Wait for the entities engine to start + get_entities_engine().wait_start() + with self._get_session() as session: db_alarms = { str(alarm.name): alarm for alarm in session.query(DbAlarm).all() From 0fcaf3f87ea1e5e2d8eb8a12ea80e40bffcc52a3 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Fri, 20 Jun 2025 22:24:38 +0200 Subject: [PATCH 09/18] Added persistent storage+sync for notes --- platypush/common/notes.py | 38 +- ...bc6eb9c81_added_tables_for_note_plugins.py | 203 +++++++++ platypush/plugins/_notes/__init__.py | 394 +++++++++++------- platypush/plugins/_notes/_model.py | 55 +++ platypush/plugins/_notes/db/__init__.py | 5 + platypush/plugins/_notes/db/_mixin.py | 221 ++++++++++ platypush/plugins/_notes/db/_model.py | 152 +++++++ platypush/plugins/joplin/__init__.py | 9 +- 8 files changed, 908 insertions(+), 169 deletions(-) create mode 100644 platypush/migrations/alembic/versions/692bc6eb9c81_added_tables_for_note_plugins.py create mode 100644 platypush/plugins/_notes/_model.py create mode 100644 platypush/plugins/_notes/db/__init__.py create mode 100644 platypush/plugins/_notes/db/_mixin.py create mode 100644 platypush/plugins/_notes/db/_model.py diff --git a/platypush/common/notes.py b/platypush/common/notes.py index 1e8a5da0c..c3d9f624a 100644 --- a/platypush/common/notes.py +++ b/platypush/common/notes.py @@ -1,14 +1,15 @@ -from abc import abstractmethod +from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from hashlib import sha256 +from hashlib import md5, sha256 from typing import Any, Dict, List, Optional, Set +from uuid import UUID from platypush.message import JSONAble from platypush.schemas.notes import NoteCollectionSchema, NoteItemSchema -class Serializable(JSONAble): +class Serializable(JSONAble, ABC): """ Base class for serializable objects. """ @@ -23,7 +24,26 @@ class Serializable(JSONAble): return self.to_dict() -@dataclass +@dataclass(kw_only=True) +class Storable(Serializable, ABC): + """ + Base class for note objects that can be represented as databases entries. + """ + + id: Any + plugin: str + + @property + def _db_id(self) -> UUID: + """ + Generate a deterministic UUID based on the note's plugin and ID. + """ + key = f'{self.plugin}:{self.id}' + digest = md5(key.encode()).digest() + return UUID(int=int.from_bytes(digest, 'little')) + + +@dataclass(kw_only=True) class NoteSource(Serializable): """ Represents a source for a note, such as a URL or file path. @@ -37,13 +57,12 @@ class NoteSource(Serializable): return self.__dict__ -@dataclass -class Note(Serializable): +@dataclass(kw_only=True) +class Note(Storable): """ Represents a note with a title and content. """ - id: Any title: str description: Optional[str] = None content: Optional[str] = None @@ -89,13 +108,12 @@ class Note(Serializable): ) -@dataclass -class NoteCollection(Serializable): +@dataclass(kw_only=True) +class NoteCollection(Storable): """ Represents a collection of notes. """ - id: Any title: str description: Optional[str] = None parent: Optional['NoteCollection'] = None diff --git a/platypush/migrations/alembic/versions/692bc6eb9c81_added_tables_for_note_plugins.py b/platypush/migrations/alembic/versions/692bc6eb9c81_added_tables_for_note_plugins.py new file mode 100644 index 000000000..3a5dbadd8 --- /dev/null +++ b/platypush/migrations/alembic/versions/692bc6eb9c81_added_tables_for_note_plugins.py @@ -0,0 +1,203 @@ +"""Added tables for note plugins + +Revision ID: 692bc6eb9c81 +Revises: 0876439530cb +Create Date: 2025-06-20 12:27:54.533624 + +""" + +from alembic import op +import sqlalchemy as sa + +from platypush.plugins._notes.db._model import ( + Note as DbNote, + NoteCollection as DbNoteCollection, + NoteNoteResource as DbNoteNoteResource, + NoteResource as DbNoteResource, + NoteTag as DbNoteTag, +) + + +# revision identifiers, used by Alembic. +revision = '692bc6eb9c81' +down_revision = '0876439530cb' +branch_labels = None +depends_on = None + + +def _create_note_collection_table(metadata: sa.MetaData) -> sa.Table: + table_name = DbNoteCollection.__tablename__ + existing_table = metadata.tables.get(table_name) + if existing_table is not None: + print(f'Table `{table_name}` already exists, skipping creation') + return existing_table + + table = op.create_table( + table_name, + sa.Column('id', sa.UUID, primary_key=True), + sa.Column('external_id', sa.String, nullable=False), + sa.Column('plugin', sa.String, nullable=False), + sa.Column('title', sa.String, nullable=False), + sa.Column('description', sa.String, nullable=True), + sa.Column( + 'parent_id', sa.UUID, sa.ForeignKey(f'{table_name}.id'), nullable=True + ), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + ) + + sa.Index( + 'note_collection_external_id_plugin_idx', + table.c.external_id, + table.c.plugin, + unique=True, + ) + + return table + + +def _create_note_resource_table(metadata: sa.MetaData) -> sa.Table: + table_name = DbNoteResource.__tablename__ + existing_table = metadata.tables.get(table_name) + if existing_table is not None: + print(f'Table `{table_name}` already exists, skipping creation') + return existing_table + + table = op.create_table( + table_name, + sa.Column('id', sa.UUID, primary_key=True), + sa.Column('external_id', sa.String, nullable=False), + sa.Column('plugin', sa.String, nullable=False), + sa.Column('title', sa.String, nullable=False), + sa.Column('filename', sa.String, nullable=False), + sa.Column('content_type', sa.String, nullable=True), + sa.Column('size', sa.Integer, nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=True), + ) + + sa.Index( + 'note_resource_external_id_plugin_idx', + table.c.external_id, + table.c.plugin, + unique=True, + ) + + return table + + +def _create_note_table(metadata: sa.MetaData) -> sa.Table: + table_name = DbNote.__tablename__ + existing_table = metadata.tables.get(table_name) + if existing_table is not None: + print(f'Table `{table_name}` already exists, skipping creation') + return existing_table + + table = op.create_table( + table_name, + sa.Column('id', sa.UUID, primary_key=True), + sa.Column('external_id', sa.String, nullable=False), + sa.Column('plugin', sa.String, nullable=False), + sa.Column('title', sa.String, nullable=False), + sa.Column('description', sa.String, nullable=True), + sa.Column('content', sa.String, nullable=True), + sa.Column( + 'parent_id', + sa.UUID, + sa.ForeignKey(f'{DbNoteCollection.__tablename__}.id'), + nullable=True, + ), + sa.Column('digest', sa.String, nullable=True, index=True), + sa.Column('latitude', sa.Float, nullable=True), + sa.Column('longitude', sa.Float, nullable=True), + sa.Column('altitude', sa.Float, nullable=True), + sa.Column('author', sa.String, nullable=True), + sa.Column('source_name', sa.String, nullable=True, index=True), + sa.Column('source_url', sa.String, nullable=True, index=True), + sa.Column('source_app', sa.String, nullable=True, index=True), + sa.Column('created_at', sa.DateTime, nullable=False), + sa.Column('updated_at', sa.DateTime, nullable=False), + ) + + sa.Index( + 'note_external_id_plugin_idx', + table.c.external_id, + table.c.plugin, + unique=True, + ) + + return table + + +def _create_note_note_resource_table(metadata: sa.MetaData) -> sa.Table: + table_name = DbNoteNoteResource.__tablename__ + existing_table = metadata.tables.get(table_name) + if existing_table is not None: + print(f'Table `{table_name}` already exists, skipping creation') + return existing_table + + return op.create_table( + table_name, + sa.Column('note_id', sa.UUID, sa.ForeignKey(f'{DbNote.__tablename__}.id')), + sa.Column( + 'resource_id', + sa.String, + sa.ForeignKey(f'{DbNoteResource.__tablename__}.id'), + ), + sa.PrimaryKeyConstraint('note_id', 'resource_id'), + ) + + +def _create_note_tag_table(metadata: sa.MetaData) -> sa.Table: + table_name = DbNoteTag.__tablename__ + existing_table = metadata.tables.get(table_name) + if existing_table is not None: + print(f'Table `{table_name}` already exists, skipping creation') + return existing_table + + return op.create_table( + table_name, + sa.Column( + 'note_id', + sa.UUID, + sa.ForeignKey(f'{DbNote.__tablename__}.id'), + nullable=False, + ), + sa.Column('tag', sa.String, nullable=False), + sa.PrimaryKeyConstraint('note_id', 'tag', name='note_tag_pkey'), + ) + + +def _drop_table_if_exists(table_name: str, metadata: sa.MetaData) -> None: + if table_name in metadata.tables: + op.drop_table(table_name) + print(f'Table `{table_name}` dropped successfully.') + else: + print(f'Table `{table_name}` does not exist, skipping drop.') + + +def upgrade() -> None: + conn = op.get_bind() + metadata = sa.MetaData() + metadata.reflect(bind=conn) + + _create_note_collection_table(metadata) + _create_note_resource_table(metadata) + _create_note_table(metadata) + _create_note_note_resource_table(metadata) + _create_note_tag_table(metadata) + + +def downgrade() -> None: + conn = op.get_bind() + metadata = sa.MetaData() + metadata.reflect(bind=conn) + + for table_name in [ + DbNoteTag.__tablename__, + DbNoteNoteResource.__tablename__, + DbNoteResource.__tablename__, + DbNote.__tablename__, + DbNoteCollection.__tablename__, + ]: + _drop_table_if_exists(table_name, metadata) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 2b8682cc1..434edd31e 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from threading import RLock from time import time @@ -6,6 +7,7 @@ from typing import Any, Dict, Iterable, Optional, Type from platypush.common.notes import Note, NoteCollection, NoteSource from platypush.context import Variable +from platypush.entities import get_entities_engine from platypush.message.event.notes import ( BaseNoteEvent, NoteCreatedEvent, @@ -16,10 +18,12 @@ from platypush.message.event.notes import ( CollectionDeletedEvent, ) from platypush.plugins import RunnablePlugin, action -from platypush.utils import get_plugin_name_by_class + +from .db import DbMixin +from ._model import CollectionsDelta, NotesDelta, StateDelta -class BaseNotePlugin(RunnablePlugin, ABC): +class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): """ Base class for note-taking plugins. """ @@ -30,11 +34,8 @@ class BaseNotePlugin(RunnablePlugin, ABC): If set to zero or null, the plugin will not poll for updates, and events will be generated only when you manually call :meth:`.sync`. """ - super().__init__(*args, poll_interval=poll_interval, **kwargs) - self._notes: Dict[Any, Note] = {} - self._collections: Dict[Any, NoteCollection] = {} - self._notes_lock = RLock() - self._collections_lock = RLock() + RunnablePlugin.__init__(self, *args, poll_interval=poll_interval, **kwargs) + DbMixin.__init__(self, *args, **kwargs) self._sync_lock = RLock() self.__last_sync_time: Optional[datetime] = None @@ -43,9 +44,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): """ Variable name for the last sync time. """ - return Variable( - f'_LAST_ITEMS_SYNC_TIME[{get_plugin_name_by_class(self.__class__)}]' - ) + return Variable(f'_LAST_ITEMS_SYNC_TIME[{self._plugin_name}]') @property def _last_sync_time(self) -> Optional[datetime]: @@ -232,76 +231,34 @@ class BaseNotePlugin(RunnablePlugin, ABC): for event in events: self.bus.post(event) - def _process_events( - self, - notes: Iterable[Note], - prev_notes: Dict[Any, Note], - collections: Iterable[NoteCollection], - prev_collections: Dict[Any, NoteCollection], - ): - most_recent_note = list(notes)[0] if notes else None - most_recent_collection = list(collections)[0] if collections else None - max_updated_at = max( - ( - most_recent_note.updated_at.timestamp() - if most_recent_note and most_recent_note.updated_at - else 0 - ), - ( - most_recent_collection.updated_at.timestamp() - if most_recent_collection and most_recent_collection.updated_at - else 0 - ), - ) - + def _process_events(self, state_delta: StateDelta): with self._sync_lock: - self._process_collections_events(collections, prev_collections) - self._process_notes_events(notes, prev_notes) - self._last_sync_time = ( - datetime.fromtimestamp(max_updated_at) if max_updated_at > 0 else None - ) + self._process_collections_events(state_delta.collections) + self._process_notes_events(state_delta.notes) - @classmethod def _make_event( - cls, evt_type: Type[BaseNoteEvent], *args, **kwargs + self, evt_type: Type[BaseNoteEvent], *args, **kwargs ) -> BaseNoteEvent: """ Create a note event of the specified type. """ - return evt_type(*args, plugin=get_plugin_name_by_class(cls), **kwargs) - - def _process_notes_events( - self, - notes: Iterable[Note], - prev_notes: Dict[Any, Note], - ): - last_sync_time = self._last_sync_time.timestamp() if self._last_sync_time else 0 - new_notes_by_id = {note.id: note for note in notes} + return evt_type(*args, plugin=self._plugin_name, **kwargs) + def _process_notes_events(self, notes_delta: NotesDelta): removed_note_events = [ self._make_event(NoteDeletedEvent, note=note) - for note_id, note in prev_notes.items() - if note_id not in new_notes_by_id + for note in notes_delta.deleted.values() ] - created_note_events = [] - updated_note_events = [] + created_note_events = [ + self._make_event(NoteCreatedEvent, note=note) + for note in notes_delta.added.values() + ] - for note in notes: - created_at = note.created_at.timestamp() if note.created_at else 0 - updated_at = note.updated_at.timestamp() if note.updated_at else 0 - - if created_at > last_sync_time: - created_note_events.append( - self._make_event(NoteCreatedEvent, note=note) - ) - elif updated_at > last_sync_time: - updated_note_events.append( - self._make_event(NoteUpdatedEvent, note=note) - ) - else: - # Assuming that the list of notes is sorted by updated_at - break + updated_note_events = [ + self._make_event(NoteUpdatedEvent, note=note) + for note in notes_delta.updated.values() + ] self._dispatch_events( *removed_note_events, @@ -309,44 +266,21 @@ class BaseNotePlugin(RunnablePlugin, ABC): *updated_note_events, ) - def _process_collections_events( - self, - collections: Iterable[NoteCollection], - prev_collections: Dict[Any, NoteCollection], - ): - last_sync_time = self._last_sync_time.timestamp() if self._last_sync_time else 0 - new_collections_by_id = { - collection.id: collection for collection in collections - } - + def _process_collections_events(self, collections_delta: CollectionsDelta): removed_collection_events = [ self._make_event(CollectionDeletedEvent, collection=collection) - for collection_id, collection in prev_collections.items() - if collection_id not in new_collections_by_id + for collection in collections_delta.deleted.values() ] - created_collection_events = [] - updated_collection_events = [] + created_collection_events = [ + self._make_event(CollectionCreatedEvent, collection=collection) + for collection in collections_delta.added.values() + ] - for collection in collections: - created_at = ( - collection.created_at.timestamp() if collection.created_at else 0 - ) - updated_at = ( - collection.updated_at.timestamp() if collection.updated_at else 0 - ) - - if created_at > last_sync_time: - created_collection_events.append( - self._make_event(CollectionCreatedEvent, collection=collection) - ) - elif updated_at > last_sync_time: - updated_collection_events.append( - self._make_event(CollectionUpdatedEvent, collection=collection) - ) - else: - # Assuming that the list of collections is sorted by updated_at - break + updated_collection_events = [ + self._make_event(CollectionUpdatedEvent, collection=collection) + for collection in collections_delta.updated.values() + ] self._dispatch_events( *removed_collection_events, @@ -357,7 +291,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): def _get_note(self, note_id: Any, *args, **kwargs) -> Note: note = self._fetch_note(note_id, *args, **kwargs) assert note, f'Note with ID {note_id} not found' - with self._notes_lock: + with self._sync_lock: # Always overwrite the note in the cache, # as this is the most up-to-date complete version self._notes[note.id] = note @@ -368,7 +302,33 @@ class BaseNotePlugin(RunnablePlugin, ABC): note.id ] = note - return self._notes[note.id] + return self._notes[note.id] + + def _merge_note(self, note: Note, skip_content: bool = True) -> Note: + """ + Merge the state of an incoming note with the existing one in the cache. + """ + + existing_note = self._notes.get(note.id) + if not existing_note: + # If the note doesn't exist, just return the new one + return note + + for field in note.__dataclass_fields__: + existing_value = getattr(existing_note, field) + value = getattr(note, field) + # Don't overwrite content, digest and tags here unless they have been re-fetched + if ( + skip_content + and field in ('content', 'digest', 'tags') + and existing_value + and not value + ): + continue + + setattr(existing_note, field, value) + + return existing_note def _get_notes( self, @@ -383,9 +343,10 @@ class BaseNotePlugin(RunnablePlugin, ABC): # Always fetch if background polling is disabled fetch = fetch or not self.poll_interval if fetch: - with self._notes_lock: + with self._sync_lock: self._notes = { - note.id: note for note in self._fetch_notes(*args, **kwargs) + note.id: self._merge_note(note) + for note in self._fetch_notes(*args, **kwargs) } self._refresh_notes_cache() @@ -400,7 +361,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): def _get_collection(self, collection_id: Any, *args, **kwargs) -> NoteCollection: collection = self._fetch_collection(collection_id, *args, **kwargs) assert collection, f'Collection with ID {collection_id} not found' - with self._collections_lock: + with self._sync_lock: # Always overwrite the collection in the cache, # as this is the most up-to-date complete version self._collections[collection.id] = collection @@ -426,7 +387,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): # Always fetch if background polling is disabled fetch = fetch or not self.poll_interval if fetch: - with self._collections_lock: + with self._sync_lock: self._collections = { collection.id: collection for collection in self._fetch_collections(*args, **kwargs) @@ -486,6 +447,92 @@ class BaseNotePlugin(RunnablePlugin, ABC): }.items() } + def _get_state_delta( + self, + previous_notes: Dict[Any, Note], + previous_collections: Dict[Any, NoteCollection], + notes: Dict[Any, Note], + collections: Dict[Any, NoteCollection], + ) -> StateDelta: + """ + Get the state delta between the previous and current state of notes and collections. + + :param previous_notes: Previous notes state. + :param previous_collections: Previous collections state. + :param notes: Current notes state. + :param collections: Current collections state. + :return: A StateDelta object containing the changes. + """ + state_delta = StateDelta() + latest_updated_at = new_latest_updated_at = ( + self._last_sync_time.timestamp() if self._last_sync_time else 0 + ) + + # Get new and updated notes + for note in notes.values(): + updated_at = note.updated_at.timestamp() if note.updated_at else 0 + if updated_at > latest_updated_at: + if note.id not in previous_notes: + state_delta.notes.added[note.id] = note + else: + state_delta.notes.updated[note.id] = note + + new_latest_updated_at = max(new_latest_updated_at, updated_at) + + # Get deleted notes + for note_id in previous_notes: + if note_id not in notes: + state_delta.notes.deleted[note_id] = previous_notes[note_id] + + # Get new and updated collections + for collection in collections.values(): + updated_at = ( + collection.updated_at.timestamp() if collection.updated_at else 0 + ) + if updated_at > latest_updated_at: + if collection.id not in previous_collections: + state_delta.collections.added[collection.id] = collection + else: + state_delta.collections.updated[collection.id] = collection + + new_latest_updated_at = max(new_latest_updated_at, updated_at) + + # Get deleted collections + for collection_id in previous_collections: + if collection_id not in collections: + state_delta.collections.deleted[collection_id] = previous_collections[ + collection_id + ] + + state_delta.latest_updated_at = new_latest_updated_at + return state_delta + + def _refresh_notes(self, notes: Dict[Any, Note]): + """ + Fetch the given notes from the backend and update the cache. + """ + if not notes: + return + + self.logger.info( + 'Refreshing the state for %d notes from the backend', len(notes) + ) + + with ThreadPoolExecutor(max_workers=5) as pool: + # Fetch notes in parallel + futures = [ + pool.submit(self._fetch_note, note.id) for note in notes.values() + ] + + # Wait for all futures to complete and collect the results + results = pool.map(lambda f: f.result(), futures) + + with self._sync_lock: + self._notes.update( + {note.id: self._merge_note(note) for note in results if note} + ) + self._refresh_notes_cache() + @action def get_note(self, note_id: Any, *args, **kwargs) -> dict: """ @@ -592,7 +639,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): **kwargs, ) - with self._notes_lock: + with self._sync_lock: # Add the new note to the cache self._notes[note.id] = note @@ -638,20 +685,20 @@ class BaseNotePlugin(RunnablePlugin, ABC): .. schema:: notes.NoteItemSchema """ - with self._notes_lock: - note = self._edit_note( - note_id, - *args, - title=title, - content=content, - description=description, - collection=collection, - geo=self._parse_geo(geo) if geo else None, - author=author, - source=NoteSource(**source) if source else None, - **kwargs, - ) + note = self._edit_note( + note_id, + *args, + title=title, + content=content, + description=description, + collection=collection, + geo=self._parse_geo(geo) if geo else None, + author=author, + source=NoteSource(**source) if source else None, + **kwargs, + ) + with self._sync_lock: # Update the cache with the edited note self._notes[note.id] = note @@ -675,11 +722,12 @@ class BaseNotePlugin(RunnablePlugin, ABC): :param note_id: The ID of the note to delete. """ - with self._notes_lock: - self._delete_note(note_id, *args, **kwargs) + self._delete_note(note_id, *args, **kwargs) + + with self._sync_lock: note = self._notes.pop(note_id, None) if not note: - note = Note(id=note_id, title='') + note = Note(id=note_id, plugin=self._plugin_name, title='') # Remove the note from its parent collection if it has one if note.parent and note.parent.id in self._collections: @@ -767,7 +815,7 @@ class BaseNotePlugin(RunnablePlugin, ABC): title, *args, description=description, parent=parent, **kwargs ) - with self._collections_lock: + with self._sync_lock: # Add the new collection to the cache self._collections[collection.id] = collection @@ -776,10 +824,10 @@ class BaseNotePlugin(RunnablePlugin, ABC): parent_collection = self._collections[collection.parent.id] parent_collection.collections.append(collection) - # Trigger the collection created event - self._dispatch_events( - self._make_event(CollectionCreatedEvent, collection=collection) - ) + # Trigger the collection created event + self._dispatch_events( + self._make_event(CollectionCreatedEvent, collection=collection) + ) return collection.to_dict() @@ -805,16 +853,16 @@ class BaseNotePlugin(RunnablePlugin, ABC): .. schema:: notes.NoteCollectionSchema """ - with self._collections_lock: - collection = self._edit_collection( - collection_id, - *args, - title=title, - description=description, - parent=parent, - **kwargs, - ) + collection = self._edit_collection( + collection_id, + *args, + title=title, + description=description, + parent=parent, + **kwargs, + ) + with self._sync_lock: # Update the cache with the edited collection old_collection = self._collections.get(collection.id) self._collections[collection.id] = collection @@ -842,10 +890,10 @@ class BaseNotePlugin(RunnablePlugin, ABC): ): parent_collection.collections.append(collection) - # Trigger the collection updated event - self._dispatch_events( - self._make_event(CollectionUpdatedEvent, collection=collection) - ) + # Trigger the collection updated event + self._dispatch_events( + self._make_event(CollectionUpdatedEvent, collection=collection) + ) return collection.to_dict() @@ -856,11 +904,14 @@ class BaseNotePlugin(RunnablePlugin, ABC): :param collection_id: The ID of the collection to delete. """ - with self._collections_lock: - self._delete_collection(collection_id, *args, **kwargs) + self._delete_collection(collection_id, *args, **kwargs) + + with self._sync_lock: collection = self._collections.pop(collection_id, None) if not collection: - collection = NoteCollection(id=collection_id, title='') + collection = NoteCollection( + id=collection_id, plugin=self._plugin_name, title='' + ) # Remove the collection from its parent if it has one if collection.parent and collection.parent.id in self._collections: @@ -880,26 +931,52 @@ class BaseNotePlugin(RunnablePlugin, ABC): If ``poll_interval`` is zero or null, you can manually call this method to synchronize the notes and collections. """ + # Wait for the entities engine to start + get_entities_engine().wait_start() + t_start = time() self.logger.info('Synchronizing notes and collections...') with self._sync_lock: + # Initialize the latest state from the database if not already done + self._db_init() prev_notes = self._notes.copy() prev_collections = self._collections.copy() - notes = self._get_notes( - *args, fetch=True, sort={'updated_at': False}, **kwargs - ) - collections = self._get_collections( - *args, fetch=True, sort={'updated_at': False}, **kwargs + + # Fetch the latest version of the notes from the backend + notes = { + note.id: note + for note in self._get_notes( + *args, fetch=True, sort={'updated_at': False}, **kwargs + ) + } + + # Fetch the latest version of the collections from the backend + collections = { + collection.id: collection + for collection in self._get_collections( + *args, fetch=True, sort={'updated_at': False}, **kwargs + ) + } + + # Get the state delta between the previous and current state + state_delta = self._get_state_delta( + previous_notes=prev_notes, + previous_collections=prev_collections, + notes=notes, + collections=collections, ) - self._process_events( - notes=notes, - prev_notes=prev_notes, - collections=collections, - prev_collections=prev_collections, + # Re-fetch any notes that have been updated since the last sync + self._refresh_notes( + {**state_delta.notes.added, **state_delta.notes.updated} ) + # Update the local cache with the latest notes and collections + self._db_sync(state_delta) + self._last_sync_time = datetime.fromtimestamp(state_delta.latest_updated_at) + self._process_events(state_delta) + self.logger.info( 'Synchronization completed in %.2f seconds', time() - t_start, @@ -908,11 +985,16 @@ class BaseNotePlugin(RunnablePlugin, ABC): @action def reset_sync(self): """ - Reset the last sync time to None, forcing a full resync on the next call to - :meth:`.sync`, which in turn will re-trigger all notes and collections events. + Reset the sync state. + + 1. Reset the last sync time to None, forcing a full resync on the + next call to :meth:`.sync`, which in turn will re-trigger all + notes and collections events. + 2. Clear the local notes and collections cache. """ self.logger.info('Resetting last sync time') with self._sync_lock: + self._db_clear() self._last_sync_time = None self._notes.clear() self._collections.clear() diff --git a/platypush/plugins/_notes/_model.py b/platypush/plugins/_notes/_model.py new file mode 100644 index 000000000..56069c71c --- /dev/null +++ b/platypush/plugins/_notes/_model.py @@ -0,0 +1,55 @@ +from dataclasses import dataclass, field +from typing import Any, Dict + +from platypush.common.notes import Note, NoteCollection + + +@dataclass +class NotesDelta: + """ + Represents a delta of changes in notes. + """ + + added: Dict[Any, Note] = field(default_factory=dict) + updated: Dict[Any, Note] = field(default_factory=dict) + deleted: Dict[Any, Note] = field(default_factory=dict) + + def is_empty(self) -> bool: + """ + Check if the delta is empty (no added, updated, or deleted notes). + """ + return not (self.added or self.updated or self.deleted) + + +@dataclass +class CollectionsDelta: + """ + Represents a delta of changes in note collections. + """ + + added: Dict[Any, NoteCollection] = field(default_factory=dict) + updated: Dict[Any, NoteCollection] = field(default_factory=dict) + deleted: Dict[Any, NoteCollection] = field(default_factory=dict) + + def is_empty(self) -> bool: + """ + Check if the delta is empty (no added, updated, or deleted collections). + """ + return not (self.added or self.updated or self.deleted) + + +@dataclass +class StateDelta: + """ + Represents a delta of changes in the state of notes and collections. + """ + + notes: NotesDelta = field(default_factory=NotesDelta) + collections: CollectionsDelta = field(default_factory=CollectionsDelta) + latest_updated_at: float = 0 + + def is_empty(self) -> bool: + """ + Check if the state delta is empty (no changes in notes or collections). + """ + return self.notes.is_empty() and self.collections.is_empty() diff --git a/platypush/plugins/_notes/db/__init__.py b/platypush/plugins/_notes/db/__init__.py new file mode 100644 index 000000000..06e9ae368 --- /dev/null +++ b/platypush/plugins/_notes/db/__init__.py @@ -0,0 +1,5 @@ +from ._mixin import DbMixin + +__all__ = [ + "DbMixin", +] diff --git a/platypush/plugins/_notes/db/_mixin.py b/platypush/plugins/_notes/db/_mixin.py new file mode 100644 index 000000000..01fda4b4f --- /dev/null +++ b/platypush/plugins/_notes/db/_mixin.py @@ -0,0 +1,221 @@ +from contextlib import contextmanager +from threading import Event, RLock +from typing import Any, Dict, Generator + +from sqlalchemy.orm import Session + +from platypush.common.notes import Note, NoteCollection +from platypush.context import get_plugin +from platypush.plugins.db import DbPlugin +from platypush.utils import get_plugin_name_by_class, utcnow + +from .._model import StateDelta +from ._model import ( + Note as DbNote, + NoteCollection as DbNoteCollection, +) + + +class DbMixin: + """ + Mixin class for the database synchronization layer. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._notes: Dict[Any, Note] = {} + self._collections: Dict[Any, NoteCollection] = {} + self._sync_lock = RLock() + self._db_lock = RLock() + self._db_initialized = Event() + + @property + def _db(self) -> DbPlugin: + """ + Get the database plugin instance for the current context. + """ + db = get_plugin(DbPlugin) + assert db is not None, 'Database plugin not found' + return db + + @contextmanager + def _get_db_session(self, *args, **kwargs) -> Generator[Session, None, None]: + """ + Context manager to get a database session. + """ + with self._db_lock, self._db.get_session(*args, **kwargs) as session: + yield session + + @property + def _plugin_name(self) -> str: + """ + Get the plugin name for the current context. + """ + return get_plugin_name_by_class(self.__class__) + + def _to_db_collection(self, collection: NoteCollection) -> DbNoteCollection: + """ + Convert a NoteCollection object to a DbNoteCollection object. + """ + return DbNoteCollection( + id=collection._db_id, # pylint:disable=protected-access + external_id=collection.id, + plugin=self._plugin_name, + title=collection.title, + description=collection.description, + parent_id=collection.parent.id if collection.parent else None, + created_at=collection.created_at or utcnow(), + updated_at=collection.updated_at or utcnow(), + ) + + def _to_db_note(self, note: Note) -> DbNote: + """ + Convert a Note object to a DbNote object. + """ + return DbNote( + external_id=note.id, + plugin=self._plugin_name, + title=note.title, + description=note.description, + content=note.content, + parent_id=note.parent._db_id if note.parent else None, + digest=note.digest, + latitude=note.latitude, + longitude=note.longitude, + altitude=note.altitude, + author=note.author, + source_name=note.source.name if note.source else None, + source_url=note.source.url if note.source else None, + source_app=note.source.app if note.source else None, + created_at=note.created_at or utcnow(), + updated_at=note.updated_at or utcnow(), + ) + + def _from_db_note(self, db_note: DbNote) -> Note: + """ + Convert a DbNote object to a Note object. + """ + return Note( + id=db_note.external_id, + plugin=self._plugin_name, + title=db_note.title, # type: ignore[arg-type] + description=db_note.description, # type: ignore[arg-type] + content=db_note.content, # type: ignore[arg-type] + parent=( + self._from_db_collection(db_note.parent) # type: ignore[arg-type] + if db_note.parent + else None + ), + digest=db_note.digest, # type: ignore[arg-type] + latitude=db_note.latitude, # type: ignore[arg-type] + longitude=db_note.longitude, # type: ignore[arg-type] + altitude=db_note.altitude, # type: ignore[arg-type] + author=db_note.author, # type: ignore[arg-type] + source=( + { # type: ignore[arg-type] + 'name': db_note.source_name, + 'url': db_note.source_url, + 'app': db_note.source_app, + } + if db_note.source_name # type: ignore[arg-type] + else None + ), + created_at=db_note.created_at, # type: ignore[arg-type] + updated_at=db_note.updated_at, # type: ignore[arg-type] + ) + + def _from_db_collection(self, db_collection: DbNoteCollection) -> NoteCollection: + """ + Convert a DbNoteCollection object to a NoteCollection object. + """ + return NoteCollection( + id=db_collection.external_id, + plugin=self._plugin_name, + title=db_collection.title, # type: ignore[arg-type] + description=db_collection.description, # type: ignore[arg-type] + parent=( + self._from_db_collection(db_collection.parent) # type: ignore[arg-type] + if db_collection.parent + else None + ), + created_at=db_collection.created_at, # type: ignore[arg-type] + updated_at=db_collection.updated_at, # type: ignore[arg-type] + ) + + def _db_fetch_notes(self, session: Session) -> Dict[Any, Note]: + """ + Fetch notes from the database. + """ + return { + note.external_id: self._from_db_note(note) + for note in session.query(DbNote).filter_by(plugin=self._plugin_name).all() + } + + def _db_fetch_collections(self, session: Session) -> Dict[Any, NoteCollection]: + """ + Fetch collections from the database. + """ + return { + collection.external_id: self._from_db_collection(collection) + for collection in session.query(DbNoteCollection) + .filter_by(plugin=self._plugin_name) + .all() + } + + def _db_init(self, force: bool = False) -> None: + """ + Initialize the database by fetching notes and collections. + """ + with self._db_lock: + if self._db_initialized.is_set() and not force: + return + + with self._get_db_session() as session: + notes = self._db_fetch_notes(session) + collections = self._db_fetch_collections(session) + + with self._sync_lock: + self._notes = notes + self._collections = collections + + def _db_sync(self, state: StateDelta): + if state.is_empty(): + return + + with self._get_db_session(autoflush=False) as session: + for collection in [ + *state.collections.added.values(), + *state.collections.updated.values(), + ]: + db_collection = self._to_db_collection(collection) + session.merge(db_collection) + + for collection in state.collections.deleted.values(): + session.query(DbNoteCollection).filter_by( + id=collection._db_id # pylint:disable=protected-access + ).delete() + + session.flush() # Ensure collections are saved before notes + + for note in [*state.notes.added.values(), *state.notes.updated.values()]: + db_note = self._to_db_note(note) + session.merge(db_note) + + for note in state.notes.deleted.values(): + session.query(DbNote).filter_by( + id=note._db_id # pylint:disable=protected-access + ).delete() + + session.commit() + + def _db_clear(self) -> None: + """ + Clear the database by removing all notes and collections. + """ + with self._get_db_session() as session: + session.query(DbNote).filter_by(plugin=self._plugin_name).delete() + session.query(DbNoteCollection).filter_by(plugin=self._plugin_name).delete() + + with self._sync_lock: + self._notes.clear() + self._collections.clear() diff --git a/platypush/plugins/_notes/db/_model.py b/platypush/plugins/_notes/db/_model.py new file mode 100644 index 000000000..6fcd72dc6 --- /dev/null +++ b/platypush/plugins/_notes/db/_model.py @@ -0,0 +1,152 @@ +from uuid import uuid4 + +from sqlalchemy import ( + UUID, + Column, + DateTime, + ForeignKey, + Float, + Index, + Integer, + String, +) +from sqlalchemy.orm import relationship +from sqlalchemy.schema import PrimaryKeyConstraint + +from platypush.common.db import Base + +TABLE_PREFIX = 'notes_' + + +class NoteCollection(Base): + """ + Models the notes_collection table, which contains collections of notes. + """ + + __tablename__ = f'{TABLE_PREFIX}collection' + + id = Column(UUID, primary_key=True, nullable=False, default=uuid4) + external_id = Column(String, nullable=False) + plugin = Column(String, nullable=False) + title = Column(String, nullable=False) + description = Column(String, nullable=True) + parent_id = Column(UUID, ForeignKey(f'{TABLE_PREFIX}collection.id'), nullable=True) + created_at = Column(DateTime, nullable=False) + updated_at = Column(DateTime, nullable=False) + + parent = relationship( + 'NoteCollection', remote_side=[id], foreign_keys=[parent_id], backref='children' + ) + Index('note_collection_external_id_plugin_idx', external_id, plugin, unique=True) + + +class Note(Base): + """ + Models the notes_note table, which contains user notes. + """ + + __tablename__ = f'{TABLE_PREFIX}note' + + id = Column(UUID, primary_key=True, nullable=False, default=uuid4) + external_id = Column(String, nullable=False) + plugin = Column(String, nullable=False) + title = Column(String, nullable=False) + description = Column(String, nullable=True) + content = Column(String, nullable=True) + parent_id = Column(UUID, ForeignKey(f'{TABLE_PREFIX}collection.id'), nullable=True) + digest = Column(String, nullable=True, index=True) + latitude = Column(Float, nullable=True) + longitude = Column(Float, nullable=True) + altitude = Column(Float, nullable=True) + author = Column(String, nullable=True) + source_name = Column(String, nullable=True, index=True) + source_url = Column(String, nullable=True, index=True) + source_app = Column(String, nullable=True, index=True) + created_at = Column(DateTime, nullable=False) + updated_at = Column(DateTime, nullable=False) + + parent = relationship( + 'NoteCollection', + remote_side=[NoteCollection.id], + foreign_keys=[parent_id], + backref='notes', + ) + + tags = relationship('NoteTag', backref='note', cascade='all, delete-orphan') + resources = relationship( + 'NoteNoteResource', + backref='note', + cascade='all, delete-orphan', + ) + + Index('note_external_id_plugin_idx', external_id, plugin, unique=True) + + +class NoteTag(Base): + """ + Models the notes_tag table, which contains tags for notes. + """ + + __tablename__ = f'{TABLE_PREFIX}tag' + __table_args__ = (PrimaryKeyConstraint('note_id', 'tag', name='note_tag_pkey'),) + + note_id = Column( + UUID, + ForeignKey(f'{TABLE_PREFIX}note.id'), + primary_key=True, + nullable=False, + ) + tag = Column(String, primary_key=True, nullable=False) + + +class NoteResource(Base): + """ + Models the notes_resource table, which contains resources associated with notes. + """ + + __tablename__ = f'{TABLE_PREFIX}resource' + + id = Column(UUID, primary_key=True, nullable=False, default=uuid4) + external_id = Column(String, nullable=False) + plugin = Column(String, nullable=False) + title = Column(String, nullable=False) + filename = Column(String, nullable=False) + content_type = Column(String, nullable=True) + size = Column(Integer, nullable=True) + created_at = Column(DateTime) + updated_at = Column(DateTime) + + notes = relationship( + 'NoteNoteResource', + backref='resource', + cascade='all, delete-orphan', + ) + + Index('note_resource_external_id_plugin_idx', external_id, plugin, unique=True) + + +class NoteNoteResource(Base): + """ + Models the notes_note_resource table, which associates notes with resources. + """ + + __tablename__ = f'{TABLE_PREFIX}note_resource' + __table_args__ = ( + PrimaryKeyConstraint('note_id', 'resource_id', name='note_resource_pkey'), + ) + + note_id = Column( + UUID, + ForeignKey(f'{TABLE_PREFIX}note.id'), + primary_key=True, + nullable=False, + ) + resource_id = Column( + UUID, + ForeignKey(f'{TABLE_PREFIX}resource.id'), + primary_key=True, + nullable=False, + ) + + +# vim:sw=4:ts=4:et: diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index 61e6e05d6..d8dcb898e 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -220,12 +220,14 @@ class JoplinPlugin(BaseNotePlugin): if parent_id: parent = self._collections.get( - parent_id, NoteCollection(id=parent_id, title='') + 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'), @@ -243,6 +245,7 @@ class JoplinPlugin(BaseNotePlugin): """ 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')), @@ -259,8 +262,8 @@ class JoplinPlugin(BaseNotePlugin): f'notes/{note_id}', params={ 'fields': ','.join( - *[ - self._default_note_fields, + [ + *self._default_note_fields, 'body', # Include body content ] ) From 166ebdf6955c9a9514c3d74eb824e90f455c9f56 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Fri, 20 Jun 2025 23:01:54 +0200 Subject: [PATCH 10/18] `filter` on the notes APIs should support regular expressions. --- platypush/plugins/_notes/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 434edd31e..9a9adeefa 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -1,3 +1,4 @@ +import re from abc import ABC, abstractmethod from concurrent.futures import ThreadPoolExecutor from datetime import datetime @@ -202,7 +203,10 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): items = [ item for item in items - if all(getattr(item, k) == v for k, v in filter.items()) + if all( + re.search(v, str(getattr(item, k, '')), re.IGNORECASE) + for k, v in filter.items() + ) ] items = sorted( @@ -565,7 +569,9 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): :param sort: A dictionary specifying the fields to sort by and their order. Example: {'created_at': True} sorts by creation date in ascending order, while {'created_at': False} sorts in descending order. - :param filter: A dictionary specifying filters to apply to the collections. + :param filter: A dictionary specifying filters to apply to the notes, in the form + of a dictionary where the keys are field names and the values are regular expressions + to match against the field values. :param fetch: If True, always fetch the latest collections from the backend, regardless of the cache state (default: False). :param kwargs: Additional keyword arguments to pass to the fetch method. @@ -769,7 +775,9 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): :param sort: A dictionary specifying the fields to sort by and their order. Example: {'created_at': True} sorts by creation date in ascending order, while {'created_at': False} sorts in descending order. - :param filter: A dictionary specifying filters to apply to the collections. + :param filter: A dictionary specifying filters to apply to the collections, in the form + of a dictionary where the keys are field names and the values are regular expressions + to match against the field values. :param fetch: If True, always fetch the latest collections from the backend, regardless of the cache state (default: False). :param kwargs: Additional keyword arguments to pass to the fetch method. From 7db3e470b125ae674c1094a15e9a21ca0fa62210 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sat, 21 Jun 2025 00:26:24 +0200 Subject: [PATCH 11/18] [`notes`] Better sync delete logic - Remove the note records in batch. - Query by `<external_id, plugin>` rather than `id` alone - you never know if the SQLAlchemy respects the UUID primary key override. --- platypush/plugins/_notes/db/_mixin.py | 35 ++++++++++++++++++++------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/platypush/plugins/_notes/db/_mixin.py b/platypush/plugins/_notes/db/_mixin.py index 01fda4b4f..a5179219c 100644 --- a/platypush/plugins/_notes/db/_mixin.py +++ b/platypush/plugins/_notes/db/_mixin.py @@ -2,6 +2,7 @@ from contextlib import contextmanager from threading import Event, RLock from typing import Any, Dict, Generator +from sqlalchemy import and_ from sqlalchemy.orm import Session from platypush.common.notes import Note, NoteCollection @@ -183,6 +184,7 @@ class DbMixin: return with self._get_db_session(autoflush=False) as session: + # Add new/updated collections for collection in [ *state.collections.added.values(), *state.collections.updated.values(), @@ -190,21 +192,36 @@ class DbMixin: db_collection = self._to_db_collection(collection) session.merge(db_collection) - for collection in state.collections.deleted.values(): - session.query(DbNoteCollection).filter_by( - id=collection._db_id # pylint:disable=protected-access - ).delete() + # Delete removed collections + session.query(DbNoteCollection).filter( + and_( + DbNoteCollection.plugin == self._plugin_name, + DbNoteCollection.external_id.in_( + [ + collection.id + for collection in state.collections.deleted.values() + ] + ), + ) + ).delete() - session.flush() # Ensure collections are saved before notes + # Ensure that collections are saved before notes + session.flush() + # Add new/updated notes for note in [*state.notes.added.values(), *state.notes.updated.values()]: db_note = self._to_db_note(note) session.merge(db_note) - for note in state.notes.deleted.values(): - session.query(DbNote).filter_by( - id=note._db_id # pylint:disable=protected-access - ).delete() + # Delete removed notes + session.query(DbNote).filter( + and_( + DbNote.plugin == self._plugin_name, + DbNote.external_id.in_( + [note.id for note in state.notes.deleted.values()] + ), + ) + ).delete() session.commit() From 46873c317970931a1ea53958e91c99b87748f65f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sat, 21 Jun 2025 01:09:14 +0200 Subject: [PATCH 12/18] [`notes`] Added `search` action --- platypush/plugins/_notes/__init__.py | 99 +++++++++++++++++++++- platypush/plugins/_notes/_model.py | 39 ++++++++- platypush/plugins/joplin/__init__.py | 121 +++++++++++++++++++++++++-- platypush/utils/__init__.py | 2 +- 4 files changed, 252 insertions(+), 9 deletions(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 9a9adeefa..d627cf6ed 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime from threading import RLock from time import time -from typing import Any, Dict, Iterable, Optional, Type +from typing import Any, Dict, Iterable, List, Optional, Type from platypush.common.notes import Note, NoteCollection, NoteSource from platypush.context import Variable @@ -19,9 +19,10 @@ from platypush.message.event.notes import ( CollectionDeletedEvent, ) from platypush.plugins import RunnablePlugin, action +from platypush.utils import to_datetime from .db import DbMixin -from ._model import CollectionsDelta, NotesDelta, StateDelta +from ._model import CollectionsDelta, Item, ItemType, NotesDelta, StateDelta class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): @@ -537,6 +538,100 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): ) self._refresh_notes_cache() + @abstractmethod + def _search( + self, + query: str, + *args, + 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, + **kwargs, + ) -> List[Item]: + """ + Search for notes or collections based on the provided query and filters. + """ + + @action + def search( + self, + *args, + query: str, + item_type: ItemType = ItemType.NOTE, + 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, + **kwargs, + ): + """ + Search for notes or collections based on the provided query and filters. + + In most of the cases (but it depends on the backend) double-quoted + search terms will match exact phrases, while unquoted queries will + match any of the words in the query. + + Wildcards (again, depending on the backend) in the search terms are + also supported. + + :param query: The search query string (it will be searched in all the + fields). + :param item_type: The type of items to search for - ``note``, + ``collection``, or ``tag`` (default: ``note``). + :param include_terms: Optional dictionary of terms to include in the search. + The keys are field names and the values are strings to match against. + :param exclude_terms: Optional dictionary of terms to exclude from the search. + The keys are field names and the values are strings to exclude from the results. + :param created_before: Optional datetime ISO string or UNIX timestamp + to filter items created before this date. + :param created_after: Optional datetime ISO string or UNIX timestamp + to filter items created after this date. + :param updated_before: Optional datetime ISO string or UNIX timestamp + to filter items updated before this date. + :param updated_after: Optional datetime ISO string or UNIX timestamp + to filter items updated after this date. + :return: An iterable of matching items, format: + + .. code-block:: python + + [ + { + "type": "note", + "item": { + "id": "note-id", + "title": "Note Title", + "content": "Note content...", + "created_at": "2023-10-01T12:00:00Z", + "updated_at": "2023-10-01T12:00:00Z", + ... + } + } + ] + + """ + print('==== include_terms ===', include_terms) + return [ + item.to_dict() + for item in self._search( + query, + *args, + item_type=item_type, + include_terms=include_terms, + exclude_terms=exclude_terms, + created_before=to_datetime(created_before) if created_before else None, + created_after=to_datetime(created_after) if created_after else None, + updated_before=to_datetime(updated_before) if updated_before else None, + updated_after=to_datetime(updated_after) if updated_after else None, + **kwargs, + ) + ] + @action def get_note(self, note_id: Any, *args, **kwargs) -> dict: """ diff --git a/platypush/plugins/_notes/_model.py b/platypush/plugins/_notes/_model.py index 56069c71c..849c946eb 100644 --- a/platypush/plugins/_notes/_model.py +++ b/platypush/plugins/_notes/_model.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field +from enum import Enum from typing import Any, Dict -from platypush.common.notes import Note, NoteCollection +from platypush.common.notes import Note, NoteCollection, Serializable, Storable @dataclass @@ -53,3 +54,39 @@ class StateDelta: Check if the state delta is empty (no changes in notes or collections). """ return self.notes.is_empty() and self.collections.is_empty() + + +class ItemType(Enum): + """ + Enum representing the type of item. + """ + + NOTE = 'note' + COLLECTION = 'collection' + TAG = 'tag' + + +@dataclass +class Item(Serializable): + """ + Represents a generic note item. + """ + + type: ItemType + item: Storable + + def __post_init__(self): + """ + Validate the item type after initialization. + """ + if not isinstance(self.type, ItemType): + raise ValueError(f'Invalid item type: {self.type}') + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the item to a dictionary representation. + """ + return { + 'type': self.type.value, + 'item': self.item.to_dict(), + } diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index d8dcb898e..5c43eb808 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -1,11 +1,11 @@ -import datetime -from typing import Any, List, Optional +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 BaseNotePlugin +from platypush.plugins._notes import BaseNotePlugin, Item, ItemType class JoplinPlugin(BaseNotePlugin): @@ -141,6 +141,26 @@ class JoplinPlugin(BaseNotePlugin): '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', + } + def __init__(self, *args, host: str, port: int = 41184, token: str, **kwargs): """ :param host: The hostname or IP address of your Joplin application. @@ -206,13 +226,13 @@ class JoplinPlugin(BaseNotePlugin): ) @staticmethod - def _parse_time(t: Optional[int]) -> Optional[datetime.datetime]: + 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.datetime.fromtimestamp(t / 1000) + return datetime.fromtimestamp(t / 1000) def _to_note(self, data: dict) -> Note: parent_id = data.get('parent_id') @@ -440,5 +460,96 @@ class JoplinPlugin(BaseNotePlugin): """ 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, + **__, + ) -> List[Item]: + """ + 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())}' + + results = self._exec( + 'GET', + 'search', + params={ + 'type': api_item_type, + '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, + ), + }, + ) + + return [ + Item( + type=item_type, + item=( + self._to_note(result) + if item_type == ItemType.NOTE + else self._to_collection(result) + ), + ) + for result in (results or {}).get('items', []) + ] + # vim:sw=4:ts=4:et: diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index ea5ef460f..6eea56dd0 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -700,7 +700,7 @@ def to_datetime(t: Union[str, int, float, datetime.datetime]) -> datetime.dateti if isinstance(t, (int, float)): return datetime.datetime.fromtimestamp(t, tz=tz.tzutc()) if isinstance(t, str): - return parser.parse(t) + return parser.isoparse(t) return t From 5fa12bbf3945e0abf7f2d609c56a1b5e457e495e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sat, 21 Jun 2025 01:58:42 +0200 Subject: [PATCH 13/18] [`notes`] Support for pagination on all GET endpoints --- platypush/plugins/_notes/__init__.py | 137 +++++++++++++++++++-------- platypush/plugins/_notes/_model.py | 31 +++++- platypush/plugins/joplin/__init__.py | 137 ++++++++++++++++++--------- 3 files changed, 223 insertions(+), 82 deletions(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index d627cf6ed..3d3577e1f 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -4,7 +4,7 @@ from concurrent.futures import ThreadPoolExecutor from datetime import datetime from threading import RLock from time import time -from typing import Any, Dict, Iterable, List, Optional, Type +from typing import Any, Dict, Iterable, Optional, Type from platypush.common.notes import Note, NoteCollection, NoteSource from platypush.context import Variable @@ -22,7 +22,15 @@ from platypush.plugins import RunnablePlugin, action from platypush.utils import to_datetime from .db import DbMixin -from ._model import CollectionsDelta, Item, ItemType, NotesDelta, StateDelta +from ._model import ( + ApiSettings, + CollectionsDelta, + Item, + ItemType, + NotesDelta, + Results, + StateDelta, +) class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): @@ -83,7 +91,15 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): """ @abstractmethod - def _fetch_notes(self, *args, **kwargs) -> Iterable[Note]: + def _fetch_notes( + self, + *args, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + sort: Optional[Dict[str, bool]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + **kwargs, + ) -> Iterable[Note]: """ Don't call this directly if possible. Instead, use :meth:`.get_notes` method to retrieve notes and update the cache @@ -146,7 +162,15 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): """ @abstractmethod - def _fetch_collections(self, *args, **kwargs) -> Iterable[NoteCollection]: + def _fetch_collections( + self, + *args, + filter: Optional[Dict[str, Any]] = None, # pylint: disable=redefined-builtin + sort: Optional[Dict[str, bool]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + **kwargs, + ) -> Iterable[NoteCollection]: """ Don't call this directly if possible. Instead, use :meth:`.get_collections` to retrieve collections and update the cache @@ -216,13 +240,17 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): reverse=any(not ascending for ascending in sort.values()), ) - if offset is not None: + if offset is not None and not self._api_settings.supports_offset: items = items[offset:] - if limit is not None: + if limit is not None and not self._api_settings.supports_limit: items = items[:limit] return items + @property + def _api_settings(self) -> ApiSettings: + return ApiSettings() + def _dispatch_events(self, *events): """ Dispatch the given events to the event bus. @@ -351,7 +379,14 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): with self._sync_lock: self._notes = { note.id: self._merge_note(note) - for note in self._fetch_notes(*args, **kwargs) + for note in self._fetch_notes( + *args, + limit=limit, + offset=offset, + sort=sort, + filter=filter, + **kwargs, + ) } self._refresh_notes_cache() @@ -395,7 +430,14 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): with self._sync_lock: self._collections = { collection.id: collection - for collection in self._fetch_collections(*args, **kwargs) + for collection in self._fetch_collections( + *args, + limit=limit, + offset=offset, + sort=sort, + filter=filter, + **kwargs, + ) } self._refresh_collections_cache() @@ -550,8 +592,10 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): created_after: Optional[datetime] = None, updated_before: Optional[datetime] = None, updated_after: Optional[datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = 0, **kwargs, - ) -> List[Item]: + ) -> Results: """ Search for notes or collections based on the provided query and filters. """ @@ -568,6 +612,8 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): created_after: Optional[datetime] = None, updated_before: Optional[datetime] = None, updated_after: Optional[datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = 0, **kwargs, ): """ @@ -596,41 +642,45 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): to filter items updated before this date. :param updated_after: Optional datetime ISO string or UNIX timestamp to filter items updated after this date. + :param limit: Maximum number of items to retrieve (default: None, + meaning no limit, or depending on the default limit of the backend). + :param offset: Offset to start retrieving items from (default: 0). :return: An iterable of matching items, format: - .. code-block:: python + .. code-block:: javascript - [ - { - "type": "note", - "item": { - "id": "note-id", - "title": "Note Title", - "content": "Note content...", - "created_at": "2023-10-01T12:00:00Z", - "updated_at": "2023-10-01T12:00:00Z", - ... + { + "has_more": false + "results" [ + { + "type": "note", + "item": { + "id": "note-id", + "title": "Note Title", + "content": "Note content...", + "created_at": "2023-10-01T12:00:00Z", + "updated_at": "2023-10-01T12:00:00Z", + // ... + } } - } - ] + ] + } """ - print('==== include_terms ===', include_terms) - return [ - item.to_dict() - for item in self._search( - query, - *args, - item_type=item_type, - include_terms=include_terms, - exclude_terms=exclude_terms, - created_before=to_datetime(created_before) if created_before else None, - created_after=to_datetime(created_after) if created_after else None, - updated_before=to_datetime(updated_before) if updated_before else None, - updated_after=to_datetime(updated_after) if updated_after else None, - **kwargs, - ) - ] + return self._search( + query, + *args, + item_type=item_type, + include_terms=include_terms, + exclude_terms=exclude_terms, + created_before=to_datetime(created_before) if created_before else None, + created_after=to_datetime(created_after) if created_after else None, + updated_before=to_datetime(updated_before) if updated_before else None, + updated_after=to_datetime(updated_after) if updated_after else None, + limit=limit, + offset=offset, + **kwargs, + ).to_dict() @action def get_note(self, note_id: Any, *args, **kwargs) -> dict: @@ -1115,3 +1165,14 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): self.logger.error('Error during sync: %s', e) finally: self.wait_stop(self.poll_interval) + + +__all__ = [ + 'ApiSettings', + 'BaseNotePlugin', + 'Item', + 'ItemType', + 'Note', + 'NoteCollection', + 'NoteSource', +] diff --git a/platypush/plugins/_notes/_model.py b/platypush/plugins/_notes/_model.py index 849c946eb..f00a63637 100644 --- a/platypush/plugins/_notes/_model.py +++ b/platypush/plugins/_notes/_model.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Any, Dict +from typing import Any, Dict, Iterable from platypush.common.notes import Note, NoteCollection, Serializable, Storable @@ -90,3 +90,32 @@ class Item(Serializable): 'type': self.type.value, 'item': self.item.to_dict(), } + + +@dataclass +class Results(Serializable): + """ + Represents a collection of results, which can include notes, collections, and tags. + """ + + items: Iterable[Item] = field(default_factory=list) + has_more: bool = False + + def to_dict(self) -> Dict[str, Any]: + """ + Convert the results to a dictionary representation. + """ + return { + 'results': [item.to_dict() for item in self.items], + 'has_more': self.has_more, + } + + +@dataclass +class ApiSettings: + """ + Represents plugin-specific API settings. + """ + + supports_limit: bool = False + supports_offset: bool = False diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index 5c43eb808..349d4574d 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -5,7 +5,13 @@ from urllib.parse import urljoin import requests from platypush.common.notes import Note, NoteCollection, NoteSource -from platypush.plugins._notes import BaseNotePlugin, Item, ItemType +from platypush.plugins._notes import ( + ApiSettings, + BaseNotePlugin, + Item, + ItemType, + Results, +) class JoplinPlugin(BaseNotePlugin): @@ -272,6 +278,17 @@ class JoplinPlugin(BaseNotePlugin): 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 @@ -302,17 +319,27 @@ class JoplinPlugin(BaseNotePlugin): return self._to_note(note) # type: ignore[return-value] - def _fetch_notes(self, *_, **__) -> List[Note]: + def _fetch_notes( + self, *_, limit: Optional[int] = None, offset: Optional[int] = None, **__ + ) -> List[Note]: """ Fetch notes from Joplin. """ - notes_data = ( - self._exec( - 'GET', 'notes', params={'fields': ','.join(self._default_note_fields)} - ) - or {} - ).get('items', []) - return [self._to_note(note) for note in notes_data] + 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, @@ -402,7 +429,9 @@ class JoplinPlugin(BaseNotePlugin): return self._to_collection(collection_data) - def _fetch_collections(self, *_, **__) -> List[NoteCollection]: + def _fetch_collections( + self, *_, limit: Optional[int] = None, offset: Optional[int] = None, **__ + ) -> List[NoteCollection]: """ Fetch collections (folders) from Joplin. """ @@ -410,7 +439,11 @@ class JoplinPlugin(BaseNotePlugin): self._exec( 'GET', 'folders', - params={'fields': ','.join(self._default_collection_fields)}, + params={ + 'fields': ','.join(self._default_collection_fields), + 'limit': limit, + 'page': self._offset_to_page(offset=offset, limit=limit), + }, ) or {} ).get('items', []) @@ -496,6 +529,13 @@ class JoplinPlugin(BaseNotePlugin): return query.strip() + @property + def _api_settings(self) -> ApiSettings: + return ApiSettings( + supports_limit=True, + supports_offset=True, + ) + def _search( self, query: str, @@ -507,8 +547,10 @@ class JoplinPlugin(BaseNotePlugin): created_after: Optional[datetime] = None, updated_before: Optional[datetime] = None, updated_after: Optional[datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = 0, **__, - ) -> List[Item]: + ) -> Results: """ Search for notes or collections based on the provided query and filters. """ @@ -517,39 +559,48 @@ class JoplinPlugin(BaseNotePlugin): api_item_type ), f'Invalid item type: {item_type}. Supported types: {list(self._joplin_item_types.keys())}' - results = self._exec( - 'GET', - 'search', - params={ - 'type': api_item_type, - '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, - ), - }, + 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 [ - Item( - type=item_type, - item=( - self._to_note(result) - if item_type == ItemType.NOTE - else self._to_collection(result) - ), - ) - for result in (results or {}).get('items', []) - ] + 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: From bddad6e2d034e46aa6d08d97ec12d90bf87ee4d7 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sun, 22 Jun 2025 14:38:47 +0200 Subject: [PATCH 14/18] [`notes`] Added `path` propery to `Note` objects --- platypush/common/notes.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/platypush/common/notes.py b/platypush/common/notes.py index c3d9f624a..050adc13a 100644 --- a/platypush/common/notes.py +++ b/platypush/common/notes.py @@ -76,6 +76,7 @@ class Note(Storable): altitude: Optional[float] = None author: Optional[str] = None source: Optional[NoteSource] = None + _path: Optional[str] = None def __post_init__(self): """ @@ -83,6 +84,28 @@ class Note(Storable): """ self.digest = self._update_digest() + @property + def path(self) -> str: + # If the path is already set, return it + if self._path: + return self._path + + # Recursively build the path by expanding the parent collections + path = [] + parent = self.parent + while parent: + path.append(parent.title) + parent = parent.parent + + return '/'.join(reversed(path)) + f'/{self.title}.md' + + @path.setter + def path(self, value: str): + """ + Set the path for the note. + """ + self._path = value + def _update_digest(self) -> Optional[str]: if self.content and not self.digest: self.digest = sha256(self.content.encode('utf-8')).hexdigest() @@ -96,6 +119,7 @@ class Note(Storable): for field in self.__dataclass_fields__ if not field.startswith('_') and field != 'parent' }, + 'path': self.path, 'parent': ( { 'id': self.parent.id if self.parent else None, From f84b2358b3dd0451d5b336056296cf63c018dc2a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sun, 22 Jun 2025 14:40:22 +0200 Subject: [PATCH 15/18] [`notes`] Added customizable `timeout` to note plugin instances --- platypush/plugins/_notes/__init__.py | 6 +++++- platypush/plugins/joplin/__init__.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 3d3577e1f..fcdaf0692 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -38,15 +38,19 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): Base class for note-taking plugins. """ - def __init__(self, *args, poll_interval: float = 300, **kwargs): + def __init__( + self, *args, poll_interval: float = 300, timeout: Optional[int] = 60, **kwargs + ): """ :param poll_interval: Poll interval in seconds to check for updates (default: 300). If set to zero or null, the plugin will not poll for updates, and events will be generated only when you manually call :meth:`.sync`. + :param timeout: Timeout in seconds for the plugin operations (default: 60). """ RunnablePlugin.__init__(self, *args, poll_interval=poll_interval, **kwargs) DbMixin.__init__(self, *args, **kwargs) self._sync_lock = RLock() + self._timeout = timeout self.__last_sync_time: Optional[datetime] = None @property diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index 349d4574d..267b8d3a7 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -198,7 +198,9 @@ class JoplinPlugin(BaseNotePlugin): ) params['token'] = self.token - response = requests.request(method, url, params=params, timeout=10, **kwargs) + response = requests.request( + method, url, params=params, timeout=self._timeout, **kwargs + ) if not response.ok: err = response.text From 82822e6870243e3a8706fcde4c6218ba438ce17f Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sun, 22 Jun 2025 14:41:38 +0200 Subject: [PATCH 16/18] [`notes`] Plugin model improvements - Added `ResultsType` to note plugins results - API settings should be more granular - not only a generic `supports_limits` and `supports_offset`, but more granular flags on `notes`, `collections` and `search`. --- platypush/plugins/_notes/__init__.py | 21 +++++++++++++++++++-- platypush/plugins/_notes/_model.py | 19 +++++++++++++++++-- platypush/plugins/joplin/__init__.py | 9 +++++++-- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index fcdaf0692..233b8761d 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -29,6 +29,7 @@ from ._model import ( ItemType, NotesDelta, Results, + ResultsType, StateDelta, ) @@ -220,6 +221,7 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): def _process_results( # pylint: disable=too-many-positional-arguments self, items: Iterable[Any], + results_type: ResultsType, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[Dict[str, bool]] = None, @@ -244,9 +246,22 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): reverse=any(not ascending for ascending in sort.values()), ) - if offset is not None and not self._api_settings.supports_offset: + supports_limit = False + supports_offset = False + + if results_type == ResultsType.NOTES: + supports_limit = self._api_settings.supports_notes_limit + supports_offset = self._api_settings.supports_notes_offset + elif results_type == ResultsType.COLLECTIONS: + supports_limit = self._api_settings.supports_collections_limit + supports_offset = self._api_settings.supports_collections_offset + elif results_type == ResultsType.SEARCH: + supports_limit = self._api_settings.supports_search_limit + supports_offset = self._api_settings.supports_search_offset + + if offset is not None and not supports_offset: items = items[offset:] - if limit is not None and not self._api_settings.supports_limit: + if limit is not None and not supports_limit: items = items[:limit] return items @@ -400,6 +415,7 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): offset=offset, sort=sort, filter=filter, + results_type=ResultsType.NOTES, ) def _get_collection(self, collection_id: Any, *args, **kwargs) -> NoteCollection: @@ -451,6 +467,7 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): offset=offset, sort=sort, filter=filter, + results_type=ResultsType.COLLECTIONS, ) def _refresh_notes_cache(self): diff --git a/platypush/plugins/_notes/_model.py b/platypush/plugins/_notes/_model.py index f00a63637..035f7b74b 100644 --- a/platypush/plugins/_notes/_model.py +++ b/platypush/plugins/_notes/_model.py @@ -117,5 +117,20 @@ class ApiSettings: Represents plugin-specific API settings. """ - supports_limit: bool = False - supports_offset: bool = False + supports_notes_limit: bool = False + supports_notes_offset: bool = False + supports_collections_limit: bool = False + supports_collections_offset: bool = False + supports_search_limit: bool = False + supports_search_offset: bool = False + supports_search: bool = False + + +class ResultsType(Enum): + """ + Enum representing the type of results. + """ + + NOTES = 'notes' + COLLECTIONS = 'collections' + SEARCH = 'search' diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index 267b8d3a7..0fccad13f 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -534,8 +534,13 @@ class JoplinPlugin(BaseNotePlugin): @property def _api_settings(self) -> ApiSettings: return ApiSettings( - supports_limit=True, - supports_offset=True, + 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, ) def _search( From b2de1cd6e7aec08564342050bf3810db62cfce6e Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sun, 22 Jun 2025 15:27:58 +0200 Subject: [PATCH 17/18] [`notes`] `notes._to_db_note` should always set the deterministic UUID as the primary key --- platypush/plugins/_notes/db/_mixin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/platypush/plugins/_notes/db/_mixin.py b/platypush/plugins/_notes/db/_mixin.py index a5179219c..c41294625 100644 --- a/platypush/plugins/_notes/db/_mixin.py +++ b/platypush/plugins/_notes/db/_mixin.py @@ -74,6 +74,7 @@ class DbMixin: Convert a Note object to a DbNote object. """ return DbNote( + id=note._db_id, # pylint:disable=protected-access external_id=note.id, plugin=self._plugin_name, title=note.title, From d8cbff6a9d190cbabe7222bb74f64bd3c925b626 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello <fabio@manganiello.tech> Date: Sun, 22 Jun 2025 17:30:21 +0200 Subject: [PATCH 18/18] [`notes`] Log state delta on sync --- platypush/plugins/_notes/__init__.py | 3 +++ platypush/plugins/_notes/_model.py | 30 +++++++++++++++++++++++++++ platypush/plugins/_notes/db/_mixin.py | 2 +- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 233b8761d..fa8c8fcbd 100644 --- a/platypush/plugins/_notes/__init__.py +++ b/platypush/plugins/_notes/__init__.py @@ -1147,6 +1147,9 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): ) # Update the local cache with the latest notes and collections + if not state_delta.is_empty(): + self.logger.info('Synchronizing changes: %s', state_delta) + self._db_sync(state_delta) self._last_sync_time = datetime.fromtimestamp(state_delta.latest_updated_at) self._process_events(state_delta) diff --git a/platypush/plugins/_notes/_model.py b/platypush/plugins/_notes/_model.py index 035f7b74b..d4aef1f1f 100644 --- a/platypush/plugins/_notes/_model.py +++ b/platypush/plugins/_notes/_model.py @@ -21,6 +21,16 @@ class NotesDelta: """ return not (self.added or self.updated or self.deleted) + def __str__(self): + """ + String representation of the NotesDelta. + """ + return ( + f'NotesDelta(added={len(self.added)}, ' + f'updated={len(self.updated)}, ' + f'deleted={len(self.deleted)})' + ) + @dataclass class CollectionsDelta: @@ -38,6 +48,16 @@ class CollectionsDelta: """ return not (self.added or self.updated or self.deleted) + def __str__(self): + """ + String representation of the CollectionsDelta. + """ + return ( + f'CollectionsDelta(added={len(self.added)}, ' + f'updated={len(self.updated)}, ' + f'deleted={len(self.deleted)})' + ) + @dataclass class StateDelta: @@ -55,6 +75,16 @@ class StateDelta: """ return self.notes.is_empty() and self.collections.is_empty() + def __str__(self): + """ + String representation of the StateDelta. + """ + return ( + f'StateDelta(notes={self.notes}, ' + f'collections={self.collections}, ' + f'latest_updated_at={self.latest_updated_at})' + ) + class ItemType(Enum): """ diff --git a/platypush/plugins/_notes/db/_mixin.py b/platypush/plugins/_notes/db/_mixin.py index c41294625..facacbde2 100644 --- a/platypush/plugins/_notes/db/_mixin.py +++ b/platypush/plugins/_notes/db/_mixin.py @@ -17,7 +17,7 @@ from ._model import ( ) -class DbMixin: +class DbMixin: # pylint: disable=too-few-public-methods """ Mixin class for the database synchronization layer. """