[gnome-todo/wip/gbsneto/todoist-plugin: 2/2] todoist: stub out plugin



commit 863d74c568d4c0de5c911d84ea271cd7b1074857
Author: Georges Basile Stavracas Neto <georges stavracas gmail com>
Date:   Mon Jan 18 20:10:11 2016 -0200

    todoist: stub out plugin
    
    Doesn't work at the moment.

 configure.ac                                       |    9 +
 plugins/Makefile.am                                |    4 +
 plugins/todoist/Makefile.am                        |   35 ++
 plugins/todoist/todoist.plugin.in                  |   13 +
 plugins/todoist/todoist/__init__.py                |  204 +++++++
 plugins/todoist/todoist/api.py                     |  623 ++++++++++++++++++++
 plugins/todoist/todoist/managers/__init__.py       |    1 +
 .../todoist/todoist/managers/biz_invitations.py    |   39 ++
 .../todoist/managers/collaborator_states.py        |   19 +
 plugins/todoist/todoist/managers/collaborators.py  |   15 +
 plugins/todoist/todoist/managers/filters.py        |   70 +++
 plugins/todoist/todoist/managers/generic.py        |   50 ++
 plugins/todoist/todoist/managers/invitations.py    |   53 ++
 plugins/todoist/todoist/managers/items.py          |  185 ++++++
 plugins/todoist/todoist/managers/labels.py         |   70 +++
 .../todoist/todoist/managers/live_notifications.py |   32 +
 plugins/todoist/todoist/managers/locations.py      |   20 +
 plugins/todoist/todoist/managers/notes.py          |   84 +++
 plugins/todoist/todoist/managers/projects.py       |  102 ++++
 plugins/todoist/todoist/managers/reminders.py      |   56 ++
 plugins/todoist/todoist/managers/user.py           |   39 ++
 plugins/todoist/todoist/models.py                  |  231 ++++++++
 plugins/todoist/todoist/preferences-panel.ui       |  310 ++++++++++
 23 files changed, 2264 insertions(+), 0 deletions(-)
---
diff --git a/configure.ac b/configure.ac
index 6996911..b930791 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 da699ff..75cac35 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 0000000..01637ad
--- /dev/null
+++ b/plugins/todoist/Makefile.am
@@ -0,0 +1,35 @@
+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/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 0000000..ee0d32a
--- /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 0000000..256101e
--- /dev/null
+++ b/plugins/todoist/todoist/__init__.py
@@ -0,0 +1,204 @@
+#!/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
+from .api import TodoistAPI
+
+from gettext import gettext as _
+
+
+class GoogleLoginPage(Gtk.Box):
+
+    __gsignals__ = {
+        'account-selected': (GObject.SignalFlags.RUN_FIRST, None, (Goa.Object,)),
+        'cancel': (GObject.SignalFlags.RUN_FIRST, None, ())
+    }
+
+    def __init__(self):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+
+        # Build filename
+        _ui_file = os.path.join(os.path.dirname(__file__),
+                                'preferences-panel.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.cancel_button.connect('clicked', self._cancel_button_clicked)
+        self.listbox.connect('row-activated', self._row_activated)
+
+        self.add(self.builder.get_object('accounts_box'))
+        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.emit('cancel')
+
+
+class TodoistPreferencesPanel(Gtk.Stack):
+    api = None
+
+    __gsignals__ = {
+        'account-logged': (GObject.SignalFlags.RUN_FIRST, None, (Goa.Object,
+                                                                 object,))
+    }
+
+    def __init__(self, api):
+        Gtk.Stack.__init__(self, transition_type=Gtk.StackTransitionType.CROSSFADE)
+
+        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_named(self.welcome_box, 'default')
+        self.add_named(self.google_login_page, 'accounts')
+
+        self.google_login_button.connect('clicked', self.show_account_list)
+        self.google_login_page.connect('cancel', self.show_overview)
+        self.google_login_page.connect('account-selected', self._account_selected)
+
+    def show_account_list(self, data=None):
+        self.set_visible_child(self.google_login_page)
+
+    def show_overview(self, data=None):
+        self.set_visible_child(self.welcome_box)
+
+    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 0000000..ac9fcc2
--- /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/managers/__init__.py b/plugins/todoist/todoist/managers/__init__.py
new file mode 100644
index 0000000..40a96af
--- /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 0000000..4db7a13
--- /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 0000000..24cf728
--- /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 0000000..e9bfa4f
--- /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 0000000..d8e60f8
--- /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 0000000..9e79574
--- /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 0000000..2e89fed
--- /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 0000000..44684a0
--- /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 0000000..cbad3a0
--- /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 0000000..934cdaf
--- /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 0000000..90164bf
--- /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 0000000..68548f5
--- /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 0000000..897d75b
--- /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 0000000..e37666b
--- /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 0000000..43d8a98
--- /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 0000000..fc7fa77
--- /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 0000000..76f49b5
--- /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>


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]