diff options
author | Georges Basile Stavracas Neto <georges.stavracas@gmail.com> | 2016-01-18 20:10:11 -0200 |
---|---|---|
committer | Georges Basile Stavracas Neto <georges.stavracas@gmail.com> | 2016-02-02 21:10:35 -0200 |
commit | cc50d448fc1a0284a64ade3ec7321c9f6c047167 (patch) | |
tree | a07b6711f2a7b90b0b81ac3a4919095144615d93 | |
parent | d5a5c86009c31b608afcdc71e0dce416653fc9b7 (diff) | |
download | gnome-todo-wip/gbsneto/todoist-plugin.tar.gz |
todoist: stub out pluginwip/gbsneto/todoist-plugin
Doesn't work at the moment.
24 files changed, 2403 insertions, 0 deletions
diff --git a/configure.ac b/configure.ac index 3794adad..b7bf0611 100644 --- a/configure.ac +++ b/configure.ac @@ -83,8 +83,14 @@ APPSTREAM_XML dnl ================================================================ dnl Plugins dnl ================================================================ + +# Evolution-Data-Server (builtin) GNOME_TODO_ADD_PLUGIN([eds], [Evolution-Data-Server], [yes]) +# Todoist +GNOME_TODO_ADD_PLUGIN([todoist], [Todoist], [yes]) + + AC_CONFIG_FILES([ Makefile src/Makefile @@ -112,5 +118,8 @@ echo " warning flags: ${GNOME_TODO_WARN_CFLAGS} ${GNOME_TODO_WARN_LDFLAGS} release: ${ax_is_release} + Plugins: + Todoist: ${enable_todoist_plugin} + Now type 'make' to build $PACKAGE " diff --git a/plugins/Makefile.am b/plugins/Makefile.am index da699ffc..75cac356 100644 --- a/plugins/Makefile.am +++ b/plugins/Makefile.am @@ -1,3 +1,7 @@ SUBDIRS = eds +if BUILD_TODOIST_PLUGIN +SUBDIRS += todoist +endif + MAINTAINERCLEANFILES = Makefile.in diff --git a/plugins/todoist/Makefile.am b/plugins/todoist/Makefile.am new file mode 100644 index 00000000..284bbf55 --- /dev/null +++ b/plugins/todoist/Makefile.am @@ -0,0 +1,36 @@ +include $(top_srcdir)/common.am + +EXTRA_DIST = $(plugin_DATA) + +todoistplugindir = $(plugindir)/todoist +todoistplugin_DATA = \ + todoist.plugin +nobase_todoistplugin_DATA = \ + todoist/__init__.py \ + todoist/preferences-panel.ui \ + todoist/google-accounts.ui \ + todoist/models.py \ + todoist/api.py \ + todoist/managers/biz_invitations.py \ + todoist/managers/collaborators.py \ + todoist/managers/collaborator_states.py \ + todoist/managers/filters.py \ + todoist/managers/generic.py \ + todoist/managers/__init__.py \ + todoist/managers/invitations.py \ + todoist/managers/items.py \ + todoist/managers/labels.py \ + todoist/managers/live_notifications.py \ + todoist/managers/locations.py \ + todoist/managers/notes.py \ + todoist/managers/projects.py \ + todoist/managers/reminders.py \ + todoist/managers/user.py + +EXTRA_DIST = \ + $(resource_files) \ + todoist.plugin.in + +GITIGNOREFILES = \ + todoist/__pycache__ \ + todoist/managers/__pycache__ diff --git a/plugins/todoist/todoist.plugin.in b/plugins/todoist/todoist.plugin.in new file mode 100644 index 00000000..ee0d32ac --- /dev/null +++ b/plugins/todoist/todoist.plugin.in @@ -0,0 +1,13 @@ +[Plugin] +Name = Todoist +Module = todoist +Description = Todoist integration for GNOME To Do +Version = @VERSION@ +Authors = Georges Basile Stavracas Neto <gbsneto@gnome.org> +Copyright = Copyleft © The To Do maintainers +Website = https://wiki.gnome.org/Apps/Todo +Builtin = false +Hidden = false +License = GPL +Loader = python3 +Depends = diff --git a/plugins/todoist/todoist/__init__.py b/plugins/todoist/todoist/__init__.py new file mode 100644 index 00000000..3d35d566 --- /dev/null +++ b/plugins/todoist/todoist/__init__.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 + +# todoist.py +# +# Copyright (C) 2016 Georges Basile Stavracas Neto <georges.stavracas@gmail.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +import gi + +gi.require_version('Goa', '1.0') +gi.require_version('Gtd', '1.0') +gi.require_version('Peas', '1.0') + +from gi.repository import Gtd +from gi.repository import Peas +from gi.repository import Gio +from gi.repository import GLib +from gi.repository import GObject +from gi.repository import Gtk +from gi.repository import Goa + +import os +import pexpect +from .api import TodoistAPI + +from gettext import gettext as _ + + +class GoogleLoginPage(Gtk.Revealer): + + __gsignals__ = { + 'account-selected': (GObject.SignalFlags.RUN_FIRST, None, (Goa.Object,)), + 'cancel': (GObject.SignalFlags.RUN_FIRST, None, ()) + } + + def __init__(self): + Gtk.Revealer.__init__(self, transition_type=Gtk.RevealerTransitionType.SLIDE_UP, + halign=Gtk.Align.CENTER, + valign=Gtk.Align.CENTER) + + # Build filename + _ui_file = os.path.join(os.path.dirname(__file__), 'google-accounts.ui') + + self.builder = Gtk.Builder.new_from_file(_ui_file) + self.listbox = self.builder.get_object('accounts_listbox') + self.cancel_button = self.builder.get_object('cancel_button') + self.goa_label = self.builder.get_object('not_listed_account_label') + + self.cancel_button.connect('clicked', self._cancel_button_clicked) + self.listbox.connect('row-activated', self._row_activated) + self.goa_label.connect('activate-link', self._add_google_account) + + self.add(self.builder.get_object('google_accounts_frame')) + self.show_all() + + # Load the GOA client, so we can link the Google + # account from Online Accounts to Todoist Google + # authenticator. + Goa.Client.new(None, self._goa_client_ready) + + def _goa_client_ready(self, source, res, data=None): + """ Callback for when GOA client is ready """ + self.goa_client = Goa.Client.new_finish(res) + + for obj in self.goa_client.get_accounts(): + self._account_added(obj) + + self.goa_client.connect('account-added', self._account_added) + self.goa_client.connect('account-removed', self._account_removed) + + def _account_added(self, obj): + """ Add the GOA account if it's a Google account """ + if obj.get_account().props.provider_type != 'google': + return + + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + box.set_border_width(6) + box.add(Gtk.Image.new_from_icon_name('goa-account-google', + Gtk.IconSize.LARGE_TOOLBAR)) + box.add(Gtk.Label(label=obj.get_account().props.identity)) + + row = Gtk.ListBoxRow() + row.add(box) + row.show_all() + + row.goa_object = obj + self.listbox.add(row) + + def _account_removed(self, obj): + """ Remove the GOA account """ + identity = obj.get_account().props.identity + + for row in self.listbox.get_children(): + if row.goa_object.get_account().props.identity == identity: + self.listbox.remove(row) + + def _row_activated(self, listbox, row, data=None): + self.emit('account-selected', row.goa_object) + + def _cancel_button_clicked(self, data=None): + self.set_reveal_child(False) + + def _add_google_account(self, uri, data): + pexpect.run('gnome-control-center online-accounts add google') + return False + +class TodoistPreferencesPanel(Gtk.Overlay): + api = None + + __gsignals__ = { + 'account-logged': (GObject.SignalFlags.RUN_FIRST, None, (Goa.Object, + object,)) + } + + def __init__(self, api): + Gtk.Overlay.__init__(self) + + self.api = api + + # Build filename + _ui_file = os.path.join(os.path.dirname(__file__), 'preferences-panel.ui') + + self.builder = Gtk.Builder.new_from_file(_ui_file) + self.welcome_box = self.builder.get_object('welcome_box') + self.email_entry = self.builder.get_object('email_entry') + self.password_entry = self.builder.get_object('password_entry') + self.google_login_button = self.builder.get_object('google_login_button') + self.google_login_page = GoogleLoginPage() + + self.add(self.welcome_box) + self.add_overlay(self.google_login_page) + + self.google_login_button.connect('clicked', self.show_account_list) + self.google_login_page.connect('account-selected', self._account_selected) + + def show_account_list(self, data=None): + self.google_login_page.set_reveal_child(True) + + def _account_selected(self, panel, goa_object): + account = goa_object.get_account() + oauth2 = goa_object.get_oauth2_based() + + oauth2.call_get_access_token(None, + self._access_token_retrieved, + goa_object) + + def _access_token_retrieved(self, source, res, goa_object): + account = goa_object.get_account() + (access_token, timeout) = source.call_get_access_token_finish(res) + + user = self.api.login_with_google(account.props.identity, access_token) + + self.emit('account-logged', goa_object, user) + + +class TodoistPlugin(GObject.Object, Gtd.Activatable): + user = None + provider = None + goa_object = None + + preferences_panel = GObject.Property(type=Gtk.Widget, default=None) + + def __init__(self): + GObject.Object.__init__(self) + # Todoist API + self.api = TodoistAPI() + + # Preferences panel + self.preferences_panel = TodoistPreferencesPanel(self.api) + self.preferences_panel.connect('account-logged', self._account_logged) + + def do_activate(self): + pass + + def do_deactivate(self): + pass + + def do_get_preferences_panel(self): + return self.preferences_panel + + def do_get_header_widgets(self): + return None + + def do_get_panels(self): + return None + + def do_get_providers(self): + return None + + def _account_logged(self, panel, goa_object, user): + if user is not None: + self.user = user + self.goa_object = goa_object + print(user) diff --git a/plugins/todoist/todoist/api.py b/plugins/todoist/todoist/api.py new file mode 100644 index 00000000..ac9fcc29 --- /dev/null +++ b/plugins/todoist/todoist/api.py @@ -0,0 +1,623 @@ +import uuid +import json +import requests + +from todoist import models +from todoist.managers.biz_invitations import BizInvitationsManager +from todoist.managers.filters import FiltersManager +from todoist.managers.invitations import InvitationsManager +from todoist.managers.live_notifications import LiveNotificationsManager +from todoist.managers.notes import NotesManager, ProjectNotesManager +from todoist.managers.projects import ProjectsManager +from todoist.managers.items import ItemsManager +from todoist.managers.labels import LabelsManager +from todoist.managers.reminders import RemindersManager +from todoist.managers.locations import LocationsManager +from todoist.managers.user import UserManager +from todoist.managers.collaborators import CollaboratorsManager +from todoist.managers.collaborator_states import CollaboratorStatesManager + + +class TodoistAPI(object): + """ + Implements the API that makes it possible to interact with a Todoist user + account and its data. + """ + _serialize_fields = ('token', 'api_endpoint', 'seq_no', 'seq_no_partial', + 'seq_no_global', 'seq_no_global_partial', 'state', + 'temp_ids') + + @classmethod + def deserialize(cls, data): + obj = cls() + for key in cls._serialize_fields: + if key in data: + setattr(obj, key, data[key]) + return obj + + def __init__(self, token='', api_endpoint='https://api.todoist.com', session=None): + self.api_endpoint = api_endpoint + self.seq_no = 0 # Sequence number since last update + self.seq_no_partial = {} # Sequence number of partial syncs + self.seq_no_global = 0 # Global sequence number since last update + self.seq_no_global_partial = {} # Global sequence number of partial syncs + self.state = { # Local copy of all of the user's objects + 'CollaboratorStates': [], + 'Collaborators': [], + 'DayOrders': {}, + 'DayOrdersTimestamp': '', + 'Filters': [], + 'Items': [], + 'Labels': [], + 'LiveNotifications': [], + 'LiveNotificationsLastRead': -1, + 'Locations': [], + 'Notes': [], + 'ProjectNotes': [], + 'Projects': [], + 'Reminders': [], + 'Settings': {}, + 'SettingsNotifications': {}, + 'User': {}, + 'UserId': -1, + 'WebStaticVersion': -1, + } + self.token = token # User's API token + self.temp_ids = {} # Mapping of temporary ids to real ids + self.queue = [] # Requests to be sent are appended here + self.session = session or requests.Session() # Session instance for requests + + # managers + self.projects = ProjectsManager(self) + self.project_notes = ProjectNotesManager(self) + self.items = ItemsManager(self) + self.labels = LabelsManager(self) + self.filters = FiltersManager(self) + self.notes = NotesManager(self) + self.live_notifications = LiveNotificationsManager(self) + self.reminders = RemindersManager(self) + self.locations = LocationsManager(self) + self.invitations = InvitationsManager(self) + self.biz_invitations = BizInvitationsManager(self) + self.user = UserManager(self) + self.collaborators = CollaboratorsManager(self) + self.collaborator_states = CollaboratorStatesManager(self) + + def __getitem__(self, key): + return self.state[key] + + def serialize(self): + return {key: getattr(self, key) for key in self._serialize_fields} + + def get_api_url(self): + return '%s/API/v6/' % self.api_endpoint + + def _update_state(self, syncdata): + """ + Updates the local state, with the data returned by the server after a + sync. + """ + # It is straightforward to update these type of data, since it is + # enough to just see if they are present in the sync data, and then + # either replace the local values or update them. + if 'Collaborators' in syncdata: + self.state['Collaborators'] = syncdata['Collaborators'] + if 'CollaboratorStates' in syncdata: + self.state['CollaboratorStates'] = syncdata['CollaboratorStates'] + if 'DayOrders' in syncdata: + self.state['DayOrders'].update(syncdata['DayOrders']) + if 'DayOrdersTimestamp' in syncdata: + self.state['DayOrdersTimestamp'] = syncdata['DayOrdersTimestamp'] + if 'LiveNotificationsLastRead' in syncdata: + self.state['LiveNotificationsLastRead'] = \ + syncdata['LiveNotificationsLastRead'] + if 'Locations' in syncdata: + self.state['Locations'] = syncdata['Locations'] + if 'Settings' in syncdata: + self.state['Settings'].update(syncdata['Settings']) + if 'SettingsNotifications' in syncdata: + self.state['SettingsNotifications'].\ + update(syncdata['SettingsNotifications']) + if 'User' in syncdata: + self.state['User'].update(syncdata['User']) + if 'UserId' in syncdata: + self.state['UserId'] = syncdata['UserId'] + if 'WebStaticVersion' in syncdata: + self.state['WebStaticVersion'] = syncdata['WebStaticVersion'] + + # Updating these type of data is a bit more complicated, since it is + # necessary to find out whether an object in the sync data is new, + # updates an existing object, or marks an object to be deleted. But + # the same procedure takes place for each of these types of data. + for datatype in 'Filters', 'Items', 'Labels', 'LiveNotifications', \ + 'Notes', 'ProjectNotes', 'Projects', 'Reminders': + if datatype not in syncdata: + continue + + # Process each object of this specific type in the sync data. + for remoteobj in syncdata[datatype]: + # Find out whether the object already exists in the local + # state. + localobj = self._find_object(datatype, remoteobj) + if localobj is not None: + # If the object is already present in the local state, then + # we either update it, or if marked as to be deleted, we + # remove it. + if remoteobj.get('is_deleted', 0) == 0: + localobj.data.update(remoteobj) + else: + self.state[datatype].remove(localobj) + else: + # If not, then the object is new and it should be added, + # unless it is marked as to be deleted (in which case it's + # ignored). + if remoteobj.get('is_deleted', 0) == 0: + model = 'models.' + datatype[:-1] + newobj = eval(model)(remoteobj, self) + self.state[datatype].append(newobj) + + def _find_object(self, objtype, obj): + """ + Searches for an object in the local state, depending on the type of + object, and then on its primary key is. If the object is found it is + returned, and if not, then None is returned. + """ + if objtype == 'Collaborators': + return self.collaborators.get_by_id(obj['id']) + elif objtype == 'CollaboratorStates': + return self.collaborator_states.get_by_ids(obj['project_id'], + obj['user_id']) + elif objtype == 'Filters': + return self.filters.get_by_id(obj['id'], only_local=True) + elif objtype == 'Items': + return self.items.get_by_id(obj['id'], only_local=True) + elif objtype == 'Labels': + return self.labels.get_by_id(obj['id'], only_local=True) + elif objtype == 'LiveNotifications': + return self.live_notifications.get_by_key(obj['notification_key']) + elif objtype == 'Notes': + return self.notes.get_by_id(obj['id'], only_local=True) + elif objtype == 'ProjectNotes': + return self.project_notes.get_by_id(obj['id'], only_local=True) + elif objtype == 'Projects': + return self.projects.get_by_id(obj['id'], only_local=True) + elif objtype == 'Reminders': + return self.reminders.get_by_id(obj['id'], only_local=True) + else: + return None + + def _get_seq_no(self, resource_types): + """ + Calculates what is the seq_no that should be sent, based on the last + seq_no and the resource_types that are requested. + """ + seq_no = -1 + seq_no_global = -1 + + if resource_types: + for resource in resource_types: + if resource not in self.seq_no_partial: + seq_no = self.seq_no + else: + if seq_no == -1 or self.seq_no_partial[resource] < seq_no: + seq_no = self.seq_no_partial[resource] + + if resource not in self.seq_no_global_partial: + seq_no_global = self.seq_no_global + else: + if seq_no_global == -1 or \ + self.seq_no_global_partial[resource] < seq_no_global: + seq_no_global = self.seq_no_global_partial[resource] + + if seq_no == -1: + seq_no = self.seq_no + if seq_no_global == -1: + seq_no_global = self.seq_no_global + + return seq_no, seq_no_global + + def _update_seq_no(self, seq_no, seq_no_global, resource_types): + """ + Updates the seq_no and the seq_no_partial, based on the seq_no in + the response and the resource_types that were requested. + """ + if not seq_no and not seq_no_global or not resource_types: + return + if 'all' in resource_types: + if seq_no: + self.seq_no = seq_no + self.seq_no_partial = {} + if seq_no_global: + self.seq_no_global = seq_no_global + self.seq_no_global_partial = {} + else: + if seq_no and seq_no > self.seq_no: + for resource in resource_types: + self.seq_no_partial[resource] = seq_no + if seq_no_global and seq_no_global > self.seq_no_global: + for resource in resource_types: + self.seq_no_global_partial[resource] = seq_no_global + + def _replace_temp_id(self, temp_id, new_id): + """ + Replaces the temporary id generated locally when an object was first + created, with a real Id supplied by the server. True is returned if + the temporary id was found and replaced, and False otherwise. + """ + # Go through all the objects for which we expect the temporary id to be + # replaced by a real one. + for datatype in ['Filters', 'Items', 'Labels', 'Notes', 'ProjectNotes', + 'Projects', 'Reminders']: + for obj in self.state[datatype]: + if obj.temp_id == temp_id: + obj['id'] = new_id + return True + return False + + def _get(self, call, url=None, **kwargs): + """ + Sends an HTTP GET request to the specified URL, and returns the JSON + object received (if any), or whatever answer it got otherwise. + """ + if not url: + url = self.get_api_url() + + response = self.session.get(url + call, **kwargs) + + try: + return response.json() + except ValueError: + return response.text + + def _post(self, call, url=None, **kwargs): + """ + Sends an HTTP POST request to the specified URL, and returns the JSON + object received (if any), or whatever answer it got otherwise. + """ + if not url: + url = self.get_api_url() + + response = self.session.post(url + call, **kwargs) + + try: + return response.json() + except ValueError: + return response.text + + def _json_serializer(self, obj): + import datetime + if isinstance(obj, datetime.datetime): + return obj.strftime('%Y-%m-%dT%H:%M:%S') + elif isinstance(obj, datetime.date): + return obj.strftime('%Y-%m-%d') + elif isinstance(obj, datetime.time): + return obj.strftime('%H:%M:%S') + + # Sync + def generate_uuid(self): + """ + Generates a uuid. + """ + return str(uuid.uuid1()) + + def sync(self, commands=None, **kwargs): + """ + Sends to the server the changes that were made locally, and also + fetches the latest updated data from the server. + """ + data = { + 'token': self.token, + 'commands': json.dumps(commands or [], separators=',:', + default=self._json_serializer), + 'day_orders_timestamp': self.state['DayOrdersTimestamp'], + } + if not commands: + data['seq_no'], data['seq_no_global'] = \ + self._get_seq_no(kwargs.get('resource_types', None)) + + if 'include_notification_settings' in kwargs: + data['include_notification_settings'] = 1 + if 'resource_types' in kwargs: + data['resource_types'] = json.dumps(kwargs['resource_types'], + separators=',:') + data = self._post('sync', data=data) + self._update_state(data) + if not commands: + self._update_seq_no(data.get('seq_no', None), + data.get('seq_no_global', None), + kwargs.get('resource_types', None)) + + return data + + def commit(self): + """ + Commits all requests that are queued. Note that, without calling this + method none of the changes that are made to the objects are actually + synchronized to the server, unless one of the aforementioned Sync API + calls are called directly. + """ + if len(self.queue) == 0: + return + ret = self.sync(commands=self.queue) + del self.queue[:] + if 'TempIdMapping' in ret: + for temp_id, new_id in ret['TempIdMapping'].items(): + self.temp_ids[temp_id] = new_id + self._replace_temp_id(temp_id, new_id) + if 'SyncStatus' in ret: + return ret['SyncStatus'] + return ret + + # Authentication + def login(self, email, password): + """ + Logins user, and returns the response received by the server. + """ + data = self._post('login', data={'email': email, + 'password': password}) + if 'token' in data: + self.token = data['token'] + return data + + def login_with_google(self, email, oauth2_token, **kwargs): + """ + Logins user with Google account, and returns the response received by + the server. + + """ + data = {'email': email, 'oauth2_token': oauth2_token} + data.update(kwargs) + data = self._post('login_with_google', data=data) + if 'token' in data: + self.token = data['token'] + return data + + # User + def register(self, email, full_name, password, **kwargs): + """ + Registers a new user. + """ + data = {'email': email, 'full_name': full_name, 'password': password} + data.update(kwargs) + data = self._post('register', data=data) + if 'token' in data: + self.token = data['token'] + return data + + def delete_user(self, current_password, **kwargs): + """ + Deletes an existing user. + """ + params = {'token': self.token, + 'current_password': current_password} + params.update(kwargs) + return self._get('delete_user', params=params) + + # Miscellaneous + def upload_file(self, filename, **kwargs): + """ + Uploads a file. + """ + data = {'token': self.token} + data.update(kwargs) + files = {'file': open(filename, 'rb')} + return self._post('upload_file', self.get_api_url(), data=data, + files=files) + + def query(self, queries, **kwargs): + """ + Performs date queries and other searches, and returns the results. + """ + params = {'queries': json.dumps(queries, separators=',:', + default=self._json_serializer), + 'token': self.token} + params.update(kwargs) + return self._get('query', params=params) + + def get_redirect_link(self, **kwargs): + """ + Returns the absolute URL to redirect or to open in a browser. + """ + params = {'token': self.token} + params.update(kwargs) + return self._get('get_redirect_link', params=params) + + def get_productivity_stats(self): + """ + Returns the user's recent productivity stats. + """ + return self._get('get_productivity_stats', + params={'token': self.token}) + + def update_notification_setting(self, notification_type, service, + dont_notify): + """ + Updates the user's notification settings. + """ + return self._post('update_notification_setting', + data={'token': self.token, + 'notification_type': notification_type, + 'service': service, + 'dont_notify': dont_notify}) + + def get_all_completed_items(self, **kwargs): + """ + Returns all user's completed items. + """ + params = {'token': self.token} + params.update(kwargs) + return self._get('get_all_completed_items', params=params) + + def get_completed_items(self, project_id, **kwargs): + """ + Returns a project's completed items. + """ + params = {'token': self.token, + 'project_id': project_id} + params.update(kwargs) + return self._get('get_completed_items', params=params) + + def get_uploads(self, **kwargs): + """ + Returns all user's uploads. + + kwargs: + limit: (int, optional) number of results (1-50) + last_id: (int, optional) return results with id<last_id + """ + params = {'token': self.token} + params.update(kwargs) + return self._get('uploads/get', params=params) + + def delete_upload(self, file_url): + """ + Delete upload. + + param file_url: (str) uploaded file URL + """ + params = {'token': self.token, 'file_url': file_url} + return self._get('uploads/delete', params=params) + + def add_item(self, content, **kwargs): + """ + Adds a new task. + """ + params = {'token': self.token, + 'content': content} + params.update(kwargs) + return self._get('add_item', params=params) + + # Sharing + def share_project(self, project_id, email, message='', **kwargs): + """ + Appends a request to the queue, to share a project with a user. + """ + cmd = { + 'type': 'share_project', + 'temp_id': self.generate_uuid(), + 'uuid': self.generate_uuid(), + 'args': { + 'project_id': project_id, + 'email': email, + }, + } + cmd['args'].update(kwargs) + self.queue.append(cmd) + + def delete_collaborator(self, project_id, email): + """ + Appends a request to the queue, to delete a collaborator from a shared + project. + """ + cmd = { + 'type': 'delete_collaborator', + 'uuid': self.generate_uuid(), + 'args': { + 'project_id': project_id, + 'email': email, + }, + } + self.queue.append(cmd) + + def take_ownership(self, project_id): + """ + Appends a request to the queue, take ownership of a shared project. + """ + cmd = { + 'type': 'take_ownership', + 'uuid': self.generate_uuid(), + 'args': { + 'project_id': project_id, + }, + } + self.queue.append(cmd) + + # Auxiliary + def get_project(self, project_id): + """ + Gets an existing project. + """ + params = {'token': self.token, + 'project_id': project_id} + data = self._get('get_project', params=params) + obj = data.get('project', None) + if obj and 'error' not in obj: + self._update_state({'Projects': [obj]}) + return [o for o in self.state['Projects'] + if o['id'] == obj['id']][0] + return None + + def get_item(self, item_id): + """ + Gets an existing item. + """ + params = {'token': self.token, + 'item_id': item_id} + data = self._get('get_item', params=params) + obj = data.get('item', None) + if obj and 'error' not in obj: + self._update_state({'Items': [obj]}) + return [o for o in self.state['Items'] if o['id'] == obj['id']][0] + return None + + def get_label(self, label_id): + """ + Gets an existing label. + """ + params = {'token': self.token, + 'label_id': label_id} + data = self._get('get_label', params=params) + obj = data.get('label', None) + if obj and 'error' not in obj: + self._update_state({'Labels': [obj]}) + return [o for o in self.state['Labels'] if o['id'] == obj['id']][0] + return None + + def get_note(self, note_id): + """ + Gets an existing note. + """ + params = {'token': self.token, + 'note_id': note_id} + data = self._get('get_note', params=params) + obj = data.get('note', None) + if obj and 'error' not in obj: + self._update_state({'Notes': [obj]}) + return [o for o in self.state['Notes'] if o['id'] == obj['id']][0] + return None + + def get_filter(self, filter_id): + """ + Gets an existing filter. + """ + params = {'token': self.token, + 'filter_id': filter_id} + data = self._get('get_filter', params=params) + obj = data.get('filter', None) + if obj and 'error' not in obj: + self._update_state({'Filters': [obj]}) + return [o for o in self.state['Filters'] + if o['id'] == obj['id']][0] + return None + + def get_reminder(self, reminder_id): + """ + Gets an existing reminder. + """ + params = {'token': self.token, + 'reminder_id': reminder_id} + data = self._get('get_reminder', params=params) + obj = data.get('reminder', None) + if obj and 'error' not in obj: + self._update_state({'Reminders': [obj]}) + return [o for o in self.state['Reminders'] + if o['id'] == obj['id']][0] + return None + + # Class + def __repr__(self): + name = self.__class__.__name__ + unsaved = '*' if len(self.queue) > 0 else '' + email = self.user.get('email') + email_repr = repr(email) if email else '<not synchronized>' + return '%s%s(%s)' % (name, unsaved, email_repr) diff --git a/plugins/todoist/todoist/google-accounts.ui b/plugins/todoist/todoist/google-accounts.ui new file mode 100644 index 00000000..282c78c7 --- /dev/null +++ b/plugins/todoist/todoist/google-accounts.ui @@ -0,0 +1,136 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.19.0 --> +<interface> + <requires lib="gtk+" version="3.16"/> + <object class="GtkFrame" id="google_accounts_frame"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label_xalign">0</property> + <property name="shadow_type">in</property> + <child> + <object class="GtkGrid"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_left">18</property> + <property name="margin_right">18</property> + <property name="margin_top">12</property> + <property name="margin_bottom">12</property> + <child> + <object class="GtkBox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="pixel_size">62</property> + <property name="icon_name">goa-account-google</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkLabel"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Select a Google account to log in:</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow" id="scrolled_window"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <property name="min_content_width">300</property> + <property name="min_content_height">200</property> + <child> + <object class="GtkViewport" id="viewport"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="shadow_type">none</property> + <child> + <object class="GtkListBox" id="accounts_listbox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="selection_mode">none</property> + </object> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="not_listed_account_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="label" translatable="yes"><a href="test">Not listed? Add a Google account</a></property> + <property name="use_markup">True</property> + <property name="track_visited_links">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + </object> + <packing> + <property name="left_attach">0</property> + <property name="top_attach">0</property> + <property name="width">2</property> + <property name="height">2</property> + </packing> + </child> + <child> + <object class="GtkButton" id="cancel_button"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="halign">end</property> + <property name="valign">start</property> + <property name="relief">none</property> + <child> + <object class="GtkImage"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="icon_name">window-close-symbolic</property> + </object> + </child> + <style> + <class name="image-button"/> + </style> + </object> + <packing> + <property name="left_attach">1</property> + <property name="top_attach">0</property> + </packing> + </child> + </object> + </child> + <child type="label_item"> + <placeholder/> + </child> + <style> + <class name="background"/> + </style> + </object> +</interface> diff --git a/plugins/todoist/todoist/managers/__init__.py b/plugins/todoist/todoist/managers/__init__.py new file mode 100644 index 00000000..40a96afc --- /dev/null +++ b/plugins/todoist/todoist/managers/__init__.py @@ -0,0 +1 @@ +# -*- coding: utf-8 -*- diff --git a/plugins/todoist/todoist/managers/biz_invitations.py b/plugins/todoist/todoist/managers/biz_invitations.py new file mode 100644 index 00000000..4db7a131 --- /dev/null +++ b/plugins/todoist/todoist/managers/biz_invitations.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from .generic import Manager + + +class BizInvitationsManager(Manager): + + state_name = None # there is no local state associated + object_type = None # there is no object type associated + resource_type = None # there is no resource type associated + + def accept(self, invitation_id, invitation_secret): + """ + Appends a request to the queue, to accept a business invitation to + share a project. + """ + cmd = { + 'type': 'biz_accept_invitation', + 'uuid': self.api.generate_uuid(), + 'args': { + 'invitation_id': invitation_id, + 'invitation_secret': invitation_secret, + }, + } + self.queue.append(cmd) + + def reject(self, invitation_id, invitation_secret): + """ + Appends a request to the queue, to reject a business invitation to + share a project. + """ + cmd = { + 'type': 'biz_reject_invitation', + 'uuid': self.api.generate_uuid(), + 'args': { + 'invitation_id': invitation_id, + 'invitation_secret': invitation_secret, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/collaborator_states.py b/plugins/todoist/todoist/managers/collaborator_states.py new file mode 100644 index 00000000..24cf728e --- /dev/null +++ b/plugins/todoist/todoist/managers/collaborator_states.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from .generic import Manager, SyncMixin + + +class CollaboratorStatesManager(Manager, SyncMixin): + + state_name = 'CollaboratorStates' + object_type = None # there is no object type associated + resource_type = 'collaborators' + + def get_by_ids(self, project_id, user_id): + """ + Finds and returns the collaborator state based on the project and user + ids. + """ + for obj in self.state[self.state_name]: + if obj['project_id'] == project_id and obj['user_id'] == user_id: + return obj + return None diff --git a/plugins/todoist/todoist/managers/collaborators.py b/plugins/todoist/todoist/managers/collaborators.py new file mode 100644 index 00000000..e9bfa4f8 --- /dev/null +++ b/plugins/todoist/todoist/managers/collaborators.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +from .generic import Manager, GetByIdMixin, SyncMixin + + +class CollaboratorsManager(Manager, GetByIdMixin, SyncMixin): + + state_name = 'Collaborators' + object_type = None # there is no object type associated + resource_type = 'collaborators' + + def get_by_id(self, user_id): + """ + Finds and returns the collaborator based on the user id. + """ + super(CollaboratorsManager, self).get_by_id(user_id, only_local=True) diff --git a/plugins/todoist/todoist/managers/filters.py b/plugins/todoist/todoist/managers/filters.py new file mode 100644 index 00000000..d8e60f85 --- /dev/null +++ b/plugins/todoist/todoist/managers/filters.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class FiltersManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + state_name = 'Filters' + object_type = 'filter' + resource_type = 'filters' + + def add(self, name, query, **kwargs): + """ + Creates a local filter object, and appends the equivalent request to + the queue. + """ + obj = models.Filter({'name': name, 'query': query}, self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'filter_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + def update(self, filter_id, **kwargs): + """ + Updates a filter remotely, by appending the equivalent request to the + queue. + """ + args = {'id': filter_id} + args.update(kwargs) + cmd = { + 'type': 'filter_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, filter_id): + """ + Deletes a filter remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'filter_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': filter_id, + }, + } + self.queue.append(cmd) + + def update_orders(self, id_order_mapping): + """ + Updates the orders of multiple filters remotely, by appending the + equivalent request to the queue. + """ + cmd = { + 'type': 'filter_update_orders', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id_order_mapping': id_order_mapping, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/generic.py b/plugins/todoist/todoist/managers/generic.py new file mode 100644 index 00000000..9e795742 --- /dev/null +++ b/plugins/todoist/todoist/managers/generic.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +class Manager(object): + + # should be re-defined in a subclass + state_name = None + object_type = None + resource_type = None + + def __init__(self, api): + self.api = api + + # shortcuts + + @property + def state(self): + return self.api.state + + @property + def queue(self): + return self.api.queue + + +class AllMixin(object): + def all(self, filt=None): + return list(filter(filt, self.state[self.state_name])) + + +class GetByIdMixin(object): + + def get_by_id(self, obj_id, only_local=False): + """ + Finds and returns the object based on its id. + """ + for obj in self.state[self.state_name]: + if obj['id'] == obj_id or obj.temp_id == str(obj_id): + return obj + + if not only_local: + getter = getattr(self.api, 'get_%s' % self.object_type) + return getter(obj_id) + + return None + + +class SyncMixin(object): + """ + Syncs this specific type of objects. + """ + def sync(self): + return self.api.sync(resource_types=[self.resource_type]) diff --git a/plugins/todoist/todoist/managers/invitations.py b/plugins/todoist/todoist/managers/invitations.py new file mode 100644 index 00000000..2e89fedf --- /dev/null +++ b/plugins/todoist/todoist/managers/invitations.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from .generic import Manager, SyncMixin + + +class InvitationsManager(Manager, SyncMixin): + + state_name = None # there is no local state associated + object_type = 'share_invitation' + resource_type = None # there is no resource type associated + + def accept(self, invitation_id, invitation_secret): + """ + Appends a request to the queue, to accept an invitation to share a + project. + """ + cmd = { + 'type': 'accept_invitation', + 'uuid': self.api.generate_uuid(), + 'args': { + 'invitation_id': invitation_id, + 'invitation_secret': invitation_secret, + }, + } + self.queue.append(cmd) + + def reject(self, invitation_id, invitation_secret): + """ + Appends a request to the queue, to reject an invitation to share a + project. + """ + cmd = { + 'type': 'reject_invitation', + 'uuid': self.api.generate_uuid(), + 'args': { + 'invitation_id': invitation_id, + 'invitation_secret': invitation_secret, + }, + } + self.queue.append(cmd) + + def delete(self, invitation_id): + """ + Appends a request to the queue, to delete an invitation to share a + project. + """ + cmd = { + 'type': 'delete_invitation', + 'uuid': self.api.generate_uuid(), + 'args': { + 'invitation_id': invitation_id, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/items.py b/plugins/todoist/todoist/managers/items.py new file mode 100644 index 00000000..44684a07 --- /dev/null +++ b/plugins/todoist/todoist/managers/items.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class ItemsManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + state_name = 'Items' + object_type = 'item' + resource_type = 'items' + + def add(self, content, project_id, **kwargs): + """ + Creates a local item object, by appending the equivalent request to the + queue. + """ + obj = models.Item({'content': content, 'project_id': project_id}, + self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'item_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + def update(self, item_id, **kwargs): + """ + Updates an item remotely, by appending the equivalent request to the + queue. + """ + args = {'id': item_id} + args.update(kwargs) + cmd = { + 'type': 'item_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, item_ids): + """ + Deletes items remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'item_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids': item_ids + } + } + self.queue.append(cmd) + + def move(self, project_items, to_project): + """ + Moves items to another project remotely, by appending the equivalent + request to the queue. + """ + cmd = { + 'type': 'item_move', + 'uuid': self.api.generate_uuid(), + 'args': { + 'project_items': project_items, + 'to_project': to_project, + }, + } + self.queue.append(cmd) + + def close(self, item_id): + """ + Marks item as done + """ + cmd = { + 'type': 'item_close', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': item_id, + }, + } + self.queue.append(cmd) + + def complete(self, item_ids, force_history=0): + """ + Marks items as completed remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'item_complete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids': item_ids, + 'force_history': force_history, + }, + } + self.queue.append(cmd) + + def uncomplete(self, project_id, item_ids, update_item_orders=1, + restore_state=None): + """ + Marks items as not completed remotely, by appending the equivalent request to the + queue. + """ + args = { + 'project_id': project_id, + 'ids': item_ids, + 'update_item_orders': update_item_orders, + } + if restore_state: + args['restore_state'] = restore_state + cmd = { + 'type': 'item_uncomplete', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def update_date_complete(self, item_id, new_date_utc=None, date_string=None, + is_forward=None): + """ + Completes a recurring task remotely, by appending the equivalent + request to the queue. + """ + args = { + 'id': item_id, + } + if new_date_utc: + args['new_date_utc'] = new_date_utc + if date_string: + args['date_string'] = date_string + if is_forward: + args['is_forward'] = is_forward + cmd = { + 'type': 'item_update_date_complete', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def uncomplete_update_meta(self, project_id, ids_to_metas): + """ + Marks an item as completed remotely, by appending the equivalent + request to the queue. + """ + cmd = { + 'type': 'item_uncomplete_update_meta', + 'uuid': self.api.generate_uuid(), + 'args': { + 'project_id': project_id, + 'ids_to_metas': ids_to_metas, + }, + } + self.queue.append(cmd) + + def update_orders_indents(self, ids_to_orders_indents): + """ + Updates the order and indents of multiple items remotely, by appending + the equivalent request to the queue. + """ + cmd = { + 'type': 'item_update_orders_indents', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids_to_orders_indents': ids_to_orders_indents, + }, + } + self.queue.append(cmd) + + def update_day_orders(self, ids_to_orders): + """ + Updates in the local state the day orders of multiple items remotely, + by appending the equivalent request to the queue. + """ + cmd = { + 'type': 'item_update_day_orders', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids_to_orders': ids_to_orders, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/labels.py b/plugins/todoist/todoist/managers/labels.py new file mode 100644 index 00000000..cbad3a03 --- /dev/null +++ b/plugins/todoist/todoist/managers/labels.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class LabelsManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + state_name = 'Labels' + object_type = 'label' + resource_type = 'labels' + + def add(self, name, **kwargs): + """ + Creates a local label object, and appends the equivalent request to the + queue. + """ + obj = models.Label({'name': name}, self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'label_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + def update(self, label_id, **kwargs): + """ + Updates a label remotely, by appending the equivalent request to the + queue. + """ + args = {'id': label_id} + args.update(kwargs) + cmd = { + 'type': 'label_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, label_id): + """ + Deletes a label remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'label_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': label_id, + }, + } + self.queue.append(cmd) + + def update_orders(self, id_order_mapping): + """ + Updates the orders of multiple labels remotely, by appending the + equivalent request to the queue. + """ + cmd = { + 'type': 'label_update_orders', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id_order_mapping': id_order_mapping, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/live_notifications.py b/plugins/todoist/todoist/managers/live_notifications.py new file mode 100644 index 00000000..934cdaf6 --- /dev/null +++ b/plugins/todoist/todoist/managers/live_notifications.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +from .generic import Manager, AllMixin, SyncMixin + + +class LiveNotificationsManager(Manager, AllMixin, SyncMixin): + + state_name = 'LiveNotifications' + object_type = 'live_notification' + resource_type = 'live_notifications' + + def get_by_key(self, notification_key): + """ + Finds and returns live notification based on its key. + """ + for obj in self.state[self.state_name]: + if obj['notification_key'] == notification_key: + return obj + return None + + def mark_as_read(self, seq_no): + """ + Sets in the local state the last notification read, and appends the + equivalent request to the queue. + """ + cmd = { + 'type': 'live_notifications_mark_as_read', + 'uuid': self.api.generate_uuid(), + 'args': { + 'seq_no': seq_no, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/locations.py b/plugins/todoist/todoist/managers/locations.py new file mode 100644 index 00000000..90164bfe --- /dev/null +++ b/plugins/todoist/todoist/managers/locations.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from .generic import Manager, AllMixin, SyncMixin + + +class LocationsManager(Manager, AllMixin, SyncMixin): + + state_name = 'Locations' + object_type = None # there is no local state associated + resource_type = 'locations' + + def clear(self): + """ + Clears the locations. + """ + cmd = { + 'type': 'clear_locations', + 'uuid': self.api.generate_uuid(), + 'args': {}, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/notes.py b/plugins/todoist/todoist/managers/notes.py new file mode 100644 index 00000000..68548f53 --- /dev/null +++ b/plugins/todoist/todoist/managers/notes.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class GenericNotesManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + object_type = 'note' + resource_type = 'notes' + + def update(self, note_id, **kwargs): + """ + Updates an note remotely, by appending the equivalent request to the + queue. + """ + args = {'id': note_id} + args.update(kwargs) + cmd = { + 'type': 'note_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, note_id): + """ + Deletes an note remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'note_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': note_id, + }, + } + self.queue.append(cmd) + + +class NotesManager(GenericNotesManager): + + state_name = 'Notes' + + def add(self, item_id, content, **kwargs): + """ + Creates a local item note object, and appends the equivalent request to + the queue. + """ + obj = models.Note({'item_id': item_id, 'content': content}, self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'note_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + +class ProjectNotesManager(GenericNotesManager): + + state_name = 'ProjectNotes' + + def add(self, project_id, content, **kwargs): + """ + Creates a local project note object, and appends the equivalent request + to the queue. + """ + obj = models.ProjectNote({'project_id': project_id, 'content': content}, + self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'note_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj diff --git a/plugins/todoist/todoist/managers/projects.py b/plugins/todoist/todoist/managers/projects.py new file mode 100644 index 00000000..897d75bd --- /dev/null +++ b/plugins/todoist/todoist/managers/projects.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class ProjectsManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + state_name = 'Projects' + object_type = 'project' + resource_type = 'projects' + + def add(self, name, **kwargs): + """ + Creates a local project object, and appends the equivalent request to + the queue. + """ + obj = models.Project({'name': name}, self.api) + obj.temp_id = obj['id'] = '$' + self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'project_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + def update(self, project_id, **kwargs): + """ + Updates a project remotely, by appending the equivalent request to the + queue. + """ + obj = self.get_by_id(project_id) + if obj: + obj.data.update(kwargs) + + args = {'id': project_id} + args.update(kwargs) + cmd = { + 'type': 'project_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, project_ids): + """ + Deletes a project remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'project_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids': project_ids, + }, + } + self.queue.append(cmd) + + def archive(self, project_id): + """ + Marks project as archived remotely, by appending the equivalent request + to the queue. + """ + cmd = { + 'type': 'project_archive', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': project_id, + }, + } + self.queue.append(cmd) + + def unarchive(self, project_id): + """ + Marks project as not archived remotely, by appending the equivalent + request to the queue. + """ + cmd = { + 'type': 'project_unarchive', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': project_id, + }, + } + self.queue.append(cmd) + + def update_orders_indents(self, ids_to_orders_indents): + """ + Updates the orders and indents of multiple projects remotely, appending + the equivalent request to the queue. + """ + cmd = { + 'type': 'project_update_orders_indents', + 'uuid': self.api.generate_uuid(), + 'args': { + 'ids_to_orders_indents': ids_to_orders_indents, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/reminders.py b/plugins/todoist/todoist/managers/reminders.py new file mode 100644 index 00000000..e37666bb --- /dev/null +++ b/plugins/todoist/todoist/managers/reminders.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +from .. import models +from .generic import Manager, AllMixin, GetByIdMixin, SyncMixin + + +class RemindersManager(Manager, AllMixin, GetByIdMixin, SyncMixin): + + state_name = 'Reminders' + object_type = 'reminder' + resource_type = 'reminders' + + def add(self, item_id, **kwargs): + """ + Creates a local reminder object, and appends the equivalent request + to the queue. + """ + obj = models.Reminder({'item_id': item_id}, self.api) + obj.temp_id = obj['id'] = self.api.generate_uuid() + obj.data.update(kwargs) + self.state[self.state_name].append(obj) + cmd = { + 'type': 'reminder_add', + 'temp_id': obj.temp_id, + 'uuid': self.api.generate_uuid(), + 'args': obj.data, + } + self.queue.append(cmd) + return obj + + def update(self, reminder_id, **kwargs): + """ + Updates a reminder remotely, by appending the equivalent request to the + queue. + """ + args = {'id': reminder_id} + args.update(kwargs) + cmd = { + 'type': 'reminder_update', + 'uuid': self.api.generate_uuid(), + 'args': args, + } + self.queue.append(cmd) + + def delete(self, reminder_id): + """ + Deletes a reminder remotely, by appending the equivalent request to the + queue. + """ + cmd = { + 'type': 'reminder_delete', + 'uuid': self.api.generate_uuid(), + 'args': { + 'id': reminder_id, + }, + } + self.queue.append(cmd) diff --git a/plugins/todoist/todoist/managers/user.py b/plugins/todoist/todoist/managers/user.py new file mode 100644 index 00000000..43d8a987 --- /dev/null +++ b/plugins/todoist/todoist/managers/user.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from .generic import Manager + + +class UserManager(Manager): + + def update(self, **kwargs): + """ + Updates the user data, by appending the equivalent request to the queue. + """ + cmd = { + 'type': 'user_update', + 'uuid': self.api.generate_uuid(), + 'args': kwargs, + } + self.queue.append(cmd) + + def update_goals(self, **kwargs): + """ + Update the user's karma goals. + """ + cmd = { + 'type': 'update_goals', + 'uuid': self.api.generate_uuid(), + 'args': kwargs, + } + self.queue.append(cmd) + + def sync(self): + return self.api.sync(resource_types=['user']) + + def get(self, key=None, default=None): + ret = self.state['User'] + if key is not None: + ret = ret.get(key, default) + return ret + + def get_id(self): + return self.state['UserId'] diff --git a/plugins/todoist/todoist/models.py b/plugins/todoist/todoist/models.py new file mode 100644 index 00000000..fc7fa774 --- /dev/null +++ b/plugins/todoist/todoist/models.py @@ -0,0 +1,231 @@ +from pprint import pformat + + +class Model(object): + """ + Implements a generic object. + """ + def __init__(self, data, api): + self.temp_id = '' + self.data = data + self.api = api + + def __setitem__(self, key, value): + self.data[key] = value + + def __getitem__(self, key): + return self.data[key] + + def __repr__(self): + formatted_dict = pformat(dict(self.data)) + classname = self.__class__.__name__ + return '%s(%s)' % (classname, formatted_dict) + + +class Filter(Model): + """ + Implements a filter. + """ + def update(self, **kwargs): + """ + Updates filter, and appends the equivalent request to the queue. + """ + self.api.filters.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes filter, and appends the equivalent request to the queue. + """ + self.api.filters.delete(self['id']) + self.data['is_deleted'] = 1 + + +class Item(Model): + """ + Implements an item. + """ + def update(self, **kwargs): + """ + Updates item, and appends the equivalent request to the queue. + """ + self.api.items.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes item, and appends the equivalent request to the queue. + """ + self.api.items.delete([self['id']]) + self.data['is_deleted'] = 1 + + def move(self, to_project): + """ + Moves item to another project, and appends the equivalent request to + the queue. + """ + self.api.items.move({self['project_id']: [self['id']]}, to_project) + self.data['project_id'] = to_project + + def close(self): + """ + Marks item as closed + """ + self.api.items.close(self['id']) + + def complete(self, force_history=0): + """ + Marks item as completed, and appends the equivalent request to the + queue. + """ + self.api.items.complete([self['id']], force_history) + self.data['checked'] = 1 + self.data['in_history'] = force_history + + def uncomplete(self, update_item_orders=1, restore_state=None): + """ + Marks item as not completed, and appends the equivalent request to the + queue. + """ + self.api.items.uncomplete(self['project_id'], [self['id']], + update_item_orders, restore_state) + self.data['checked'] = 0 + self.data['in_history'] = 0 + if restore_state and self['id'] in restore_state: + self.data['in_history'] = restore_state[self['id']][0] + self.data['checked'] = restore_state[self['id']][1] + self.data['item_order'] = restore_state[self['id']][2] + self.data['indent'] = restore_state[self['id']][3] + + def update_date_complete(self, new_date_utc=None, date_string=None, + is_forward=None): + """ + Completes a recurring task, and appends the equivalent request to the + queue. + """ + self.api.items.update_date_complete(self['id'], new_date_utc, + date_string, is_forward) + if new_date_utc: + self.data['due_date_utc'] = new_date_utc + if date_string: + self.data['date_string'] = date_string + + +class Label(Model): + """ + Implements a label. + """ + def update(self, **kwargs): + """ + Updates label, and appends the equivalent request to the queue. + """ + self.api.labels.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes label, and appends the equivalent request to the queue. + """ + self.api.labels.delete(self['id']) + self.data['is_deleted'] = 1 + + +class LiveNotification(Model): + """ + Implements a live notification. + """ + pass + + +class GenericNote(Model): + """ + Implements a note. + """ + #: has to be defined in subclasses + local_manager = None + + def update(self, **kwargs): + """ + Updates note, and appends the equivalent request to the queue. + """ + self.local_manager.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes note, and appends the equivalent request to the queue. + """ + self.local_manager.delete(self['id']) + self.data['is_deleted'] = 1 + + +class Note(GenericNote): + """ + Implement an item note. + """ + def __init__(self, data, api): + GenericNote.__init__(self, data, api) + self.local_manager = self.api.notes + + +class ProjectNote(GenericNote): + """ + Implement a project note. + """ + def __init__(self, data, api): + GenericNote.__init__(self, data, api) + self.local_manager = self.api.project_notes + + +class Project(Model): + """ + Implements a project. + """ + def update(self, **kwargs): + """ + Updates project, and appends the equivalent request to the queue. + """ + self.api.projects.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes project, and appends the equivalent request to the queue. + """ + self.api.projects.delete([self['id']]) + self.data['is_deleted'] = 1 + + def archive(self): + """ + Marks project as archived, and appends the equivalent request to the + queue. + """ + self.api.projects.archive(self['id']) + self.data['is_archived'] = 1 + + def unarchive(self): + """ + Marks project as not archived, and appends the equivalent request to + the queue. + """ + self.api.projects.unarchive(self['id']) + self.data['is_archived'] = 0 + + +class Reminder(Model): + """ + Implements a reminder. + """ + def update(self, **kwargs): + """ + Updates reminder, and appends the equivalent request to the queue. + """ + self.api.reminders.update(self['id'], **kwargs) + self.data.update(kwargs) + + def delete(self): + """ + Deletes reminder, and appends the equivalent request to the queue. + """ + self.api.reminders.delete(self['id']) + self.data['is_deleted'] = 1 diff --git a/plugins/todoist/todoist/preferences-panel.ui b/plugins/todoist/todoist/preferences-panel.ui new file mode 100644 index 00000000..76f49b53 --- /dev/null +++ b/plugins/todoist/todoist/preferences-panel.ui @@ -0,0 +1,310 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.19.0 --> +<interface> + <requires lib="gtk+" version="3.16"/> + <object class="GtkBox" id="accounts_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="border_width">24</property> + <property name="orientation">vertical</property> + <property name="spacing">12</property> + <child> + <object class="GtkImage" id="image1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="pixel_size">62</property> + <property name="icon_name">goa-account-google</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Select a Google account to log in:</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow" id="scrolled_window"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="shadow_type">in</property> + <property name="min_content_width">300</property> + <property name="min_content_height">200</property> + <child> + <object class="GtkViewport" id="viewport"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="shadow_type">none</property> + <child> + <object class="GtkListBox" id="accounts_listbox"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="selection_mode">none</property> + </object> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">12</property> + <child type="center"> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="label" translatable="yes">Not listed? Add a Google account</property> + <property name="use_markup">True</property> + <property name="track_visited_links">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkButton" id="cancel_button"> + <property name="label" translatable="yes">Cancel</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="pack_type">end</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + </object> + <object class="GtkBox" id="welcome_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="halign">center</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + <property name="vexpand">True</property> + <property name="border_width">24</property> + <property name="orientation">vertical</property> + <property name="spacing">18</property> + <child> + <object class="GtkButton" id="google_login_button"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <child> + <object class="GtkBox" id="box3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkImage" id="google_icon_image"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="hexpand">True</property> + <property name="icon_name">goa-account-google</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child type="center"> + <object class="GtkLabel" id="google_login_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Log in with a Google Account</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + </child> + <style> + <class name="suggested-action"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box4"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="spacing">12</property> + <child> + <object class="GtkSeparator" id="separator"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">or</property> + <style> + <class name="dim-label"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkSeparator" id="separator1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="valign">center</property> + <property name="hexpand">True</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkBox" id="box2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <property name="spacing">6</property> + <child> + <object class="GtkLabel" id="email_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Email</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="email_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="width_chars">35</property> + <property name="input_purpose">email</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="password_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="margin_top">6</property> + <property name="label" translatable="yes">Password</property> + <property name="xalign">0</property> + <style> + <class name="dim-label"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + <child> + <object class="GtkEntry" id="password_entry"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="visibility">False</property> + <property name="invisible_char">●</property> + <property name="width_chars">35</property> + <property name="input_purpose">password</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">3</property> + </packing> + </child> + <child> + <object class="GtkButton" id="login_button"> + <property name="label" translatable="yes">Log in</property> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="margin_top">18</property> + <style> + <class name="suggested-action"/> + </style> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">4</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">True</property> + <property name="position">2</property> + </packing> + </child> + </object> +</interface> |