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, diff --git a/platypush/plugins/_notes/__init__.py b/platypush/plugins/_notes/__init__.py index 434edd31e..fa8c8fcbd 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 @@ -18,9 +19,19 @@ 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 ( + ApiSettings, + CollectionsDelta, + Item, + ItemType, + NotesDelta, + Results, + ResultsType, + StateDelta, +) class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): @@ -28,15 +39,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 @@ -81,7 +96,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 @@ -144,7 +167,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 @@ -190,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, @@ -202,7 +234,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( @@ -211,13 +246,30 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): reverse=any(not ascending for ascending in sort.values()), ) - if offset is not None: + 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: + if limit is not None and not 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. @@ -346,7 +398,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() @@ -356,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: @@ -390,7 +450,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() @@ -400,6 +467,7 @@ class BaseNotePlugin(RunnablePlugin, DbMixin, ABC): offset=offset, sort=sort, filter=filter, + results_type=ResultsType.COLLECTIONS, ) def _refresh_notes_cache(self): @@ -533,6 +601,108 @@ 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, + limit: Optional[int] = None, + offset: Optional[int] = 0, + **kwargs, + ) -> Results: + """ + 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, + limit: Optional[int] = None, + offset: Optional[int] = 0, + **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. + :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:: javascript + + { + "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", + // ... + } + } + ] + } + + """ + 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: """ @@ -565,7 +735,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 +941,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. @@ -973,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) @@ -1012,3 +1189,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 56069c71c..d4aef1f1f 100644 --- a/platypush/plugins/_notes/_model.py +++ b/platypush/plugins/_notes/_model.py @@ -1,7 +1,8 @@ from dataclasses import dataclass, field -from typing import Any, Dict +from enum import Enum +from typing import Any, Dict, Iterable -from platypush.common.notes import Note, NoteCollection +from platypush.common.notes import Note, NoteCollection, Serializable, Storable @dataclass @@ -20,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: @@ -37,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: @@ -53,3 +74,93 @@ 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() + + 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): + """ + 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(), + } + + +@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_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/_notes/db/_mixin.py b/platypush/plugins/_notes/db/_mixin.py index 01fda4b4f..facacbde2 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 @@ -16,7 +17,7 @@ from ._model import ( ) -class DbMixin: +class DbMixin: # pylint: disable=too-few-public-methods """ Mixin class for the database synchronization layer. """ @@ -73,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, @@ -183,6 +185,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 +193,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() diff --git a/platypush/plugins/joplin/__init__.py b/platypush/plugins/joplin/__init__.py index d8dcb898e..0fccad13f 100644 --- a/platypush/plugins/joplin/__init__.py +++ b/platypush/plugins/joplin/__init__.py @@ -1,11 +1,17 @@ -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 ( + ApiSettings, + BaseNotePlugin, + Item, + ItemType, + Results, +) class JoplinPlugin(BaseNotePlugin): @@ -141,6 +147,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. @@ -172,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 @@ -206,13 +234,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') @@ -252,6 +280,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 @@ -282,17 +321,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, @@ -382,7 +431,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. """ @@ -390,7 +441,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', []) @@ -440,5 +495,119 @@ 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() + + @property + def _api_settings(self) -> ApiSettings: + return ApiSettings( + supports_notes_limit=True, + supports_notes_offset=True, + supports_collections_limit=True, + supports_collections_offset=True, + supports_search_limit=True, + supports_search_offset=True, + supports_search=True, + ) + + def _search( + self, + query: str, + *_, + item_type: ItemType, + include_terms: Optional[Dict[str, Any]] = None, + exclude_terms: Optional[Dict[str, Any]] = None, + created_before: Optional[datetime] = None, + created_after: Optional[datetime] = None, + updated_before: Optional[datetime] = None, + updated_after: Optional[datetime] = None, + limit: Optional[int] = None, + offset: Optional[int] = 0, + **__, + ) -> Results: + """ + Search for notes or collections based on the provided query and filters. + """ + api_item_type = self._joplin_item_types.get(item_type) + assert ( + api_item_type + ), f'Invalid item type: {item_type}. Supported types: {list(self._joplin_item_types.keys())}' + + limit = limit or 100 + results = ( + self._exec( + 'GET', + 'search', + params={ + 'type': api_item_type, + 'limit': limit, + 'page': self._offset_to_page(offset=offset, limit=limit), + 'fields': ','.join( + self._default_note_fields + if item_type == ItemType.NOTE + else self._default_collection_fields + ), + 'query': self._build_search_query( + query, + include_terms=include_terms, + exclude_terms=exclude_terms, + created_before=created_before, + created_after=created_after, + updated_before=updated_before, + updated_after=updated_after, + ), + }, + ) + or {} + ) + + return Results( + has_more=bool(results.get('has_more')), + items=[ + Item( + type=item_type, + item=( + self._to_note(result) + if item_type == ItemType.NOTE + else self._to_collection(result) + ), + ) + for result in results.get('items', []) + ], + ) + # vim:sw=4:ts=4:et: 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