summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGeorges Basile Stavracas Neto <georges.stavracas@gmail.com>2016-01-18 20:10:11 -0200
committerGeorges Basile Stavracas Neto <georges.stavracas@gmail.com>2016-02-02 21:10:35 -0200
commitcc50d448fc1a0284a64ade3ec7321c9f6c047167 (patch)
treea07b6711f2a7b90b0b81ac3a4919095144615d93
parentd5a5c86009c31b608afcdc71e0dce416653fc9b7 (diff)
downloadgnome-todo-wip/gbsneto/todoist-plugin.tar.gz
todoist: stub out pluginwip/gbsneto/todoist-plugin
Doesn't work at the moment.
-rw-r--r--configure.ac9
-rw-r--r--plugins/Makefile.am4
-rw-r--r--plugins/todoist/Makefile.am36
-rw-r--r--plugins/todoist/todoist.plugin.in13
-rw-r--r--plugins/todoist/todoist/__init__.py206
-rw-r--r--plugins/todoist/todoist/api.py623
-rw-r--r--plugins/todoist/todoist/google-accounts.ui136
-rw-r--r--plugins/todoist/todoist/managers/__init__.py1
-rw-r--r--plugins/todoist/todoist/managers/biz_invitations.py39
-rw-r--r--plugins/todoist/todoist/managers/collaborator_states.py19
-rw-r--r--plugins/todoist/todoist/managers/collaborators.py15
-rw-r--r--plugins/todoist/todoist/managers/filters.py70
-rw-r--r--plugins/todoist/todoist/managers/generic.py50
-rw-r--r--plugins/todoist/todoist/managers/invitations.py53
-rw-r--r--plugins/todoist/todoist/managers/items.py185
-rw-r--r--plugins/todoist/todoist/managers/labels.py70
-rw-r--r--plugins/todoist/todoist/managers/live_notifications.py32
-rw-r--r--plugins/todoist/todoist/managers/locations.py20
-rw-r--r--plugins/todoist/todoist/managers/notes.py84
-rw-r--r--plugins/todoist/todoist/managers/projects.py102
-rw-r--r--plugins/todoist/todoist/managers/reminders.py56
-rw-r--r--plugins/todoist/todoist/managers/user.py39
-rw-r--r--plugins/todoist/todoist/models.py231
-rw-r--r--plugins/todoist/todoist/preferences-panel.ui310
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">&lt;a href="test"&gt;Not listed? Add a Google account&lt;/a&gt;</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>