├── schemas ├── gschemas.compiled └── org.gnome.shell.extensions.todoist.gschema.xml ├── assets └── todoist-gnome-shell-extension.png ├── metadata.json ├── stylesheet.css ├── utils.js ├── LICENSE ├── polyfill.js ├── README.md ├── prefs.js ├── convenience.js └── extension.js /schemas/gschemas.compiled: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubuntudroid/todoist-gnome-shell-extension/HEAD/schemas/gschemas.compiled -------------------------------------------------------------------------------- /assets/todoist-gnome-shell-extension.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ubuntudroid/todoist-gnome-shell-extension/HEAD/assets/todoist-gnome-shell-extension.png -------------------------------------------------------------------------------- /metadata.json: -------------------------------------------------------------------------------- 1 | {"name": "Todoist", "description": "Lists tasks from Todoist task manager", "shell-version": ["3.20.4"], "uuid": "todoist@ubuntudroid.gmail.com", "settings-schema": "org.gnome.shell.extensions.todoist"} 2 | -------------------------------------------------------------------------------- /stylesheet.css: -------------------------------------------------------------------------------- 1 | 2 | .helloworld-label { 3 | font-size: 36px; 4 | font-weight: bold; 5 | color: #ffffff; 6 | background-color: rgba(10,10,10,0.7); 7 | border-radius: 5px; 8 | padding: .5em; 9 | } 10 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | function isDueDateInPast(item) { 2 | if (item.due === null) return false; 3 | 4 | let dueDate = new Date(item.due.date); 5 | dueDate.setHours(0,0,0,0); 6 | let today = new Date; 7 | today.setHours(0,0,0,0); 8 | 9 | return dueDate <= today; 10 | } -------------------------------------------------------------------------------- /schemas/org.gnome.shell.extensions.todoist.gschema.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 'token' 5 | Todoist API token 6 | Personal Todoist API token as found at the bottom of the integrations settings page 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sven Bendel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /polyfill.js: -------------------------------------------------------------------------------- 1 | // https://tc39.github.io/ecma262/#sec-array.prototype.findIndex 2 | if (!Array.prototype.findIndex) { 3 | Object.defineProperty(Array.prototype, 'findIndex', { 4 | value: function(predicate) { 5 | // 1. Let O be ? ToObject(this value). 6 | if (this == null) { 7 | throw new TypeError('"this" is null or not defined'); 8 | } 9 | 10 | var o = Object(this); 11 | 12 | // 2. Let len be ? ToLength(? Get(O, "length")). 13 | var len = o.length >>> 0; 14 | 15 | // 3. If IsCallable(predicate) is false, throw a TypeError exception. 16 | if (typeof predicate !== 'function') { 17 | throw new TypeError('predicate must be a function'); 18 | } 19 | 20 | // 4. If thisArg was supplied, let T be thisArg; else let T be undefined. 21 | var thisArg = arguments[1]; 22 | 23 | // 5. Let k be 0. 24 | var k = 0; 25 | 26 | // 6. Repeat, while k < len 27 | while (k < len) { 28 | // a. Let Pk be ! ToString(k). 29 | // b. Let kValue be ? Get(O, Pk). 30 | // c. Let testResult be ToBoolean(? Call(predicate, T, « kValue, k, O »)). 31 | // d. If testResult is true, return k. 32 | var kValue = o[k]; 33 | if (predicate.call(thisArg, kValue, k, o)) { 34 | return k; 35 | } 36 | // e. Increase k by 1. 37 | k++; 38 | } 39 | 40 | // 7. Return -1. 41 | return -1; 42 | } 43 | }); 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Unmaintained: Please check out [todoist-indicator](https://github.com/tarelda/todoist-indicator) which started as a fork of this project and greatly expand on its feature set.** 2 | 3 | # Unofficial Todoist Gnome Shell extension 4 | 5 | This is an unofficial Gnome Shell extension which displays the number of currently open tasks in the top right corner of your Gnome Shell. 6 | 7 | It has not yet been submitted to the Gnome Shell extension directory and might never be as this is more a playground for experimenting with Gnome Shell. However that might change in the future. 8 | 9 | Being an absolute beginner when it comes to programming for Gnome the extension's code has been heavily influenced by [this](http://smasue.github.io/gnome-shell-tw) and [this](http://www.mibus.org/2013/02/15/making-gnome-shell-plugins-save-their-config/) blogpost. Kudos to the authors! :) 10 | 11 | ![Screenshot](assets/todoist-gnome-shell-extension.png?raw=true "Screenshot") 12 | 13 | # Setup 14 | 15 | Clone the repository to `~/.local/share/gnome-shell/extensions/` into a folder named `todoist@ubuntudroid.gmail.com` using the following command: 16 | 17 | git clone https://github.com/ubuntudroid/todoist-gnome-shell-extension.git todoist@ubuntudroid.gmail.com 18 | 19 | The name of the directory is important because Gnome Shell won't recognize the extension otherwise. 20 | 21 | Then restart Gnome Shell (ALT-F2 and then 'r') and navigate to https://extensions.gnome.org/local/. You can enable the extension and specify your Todoist API token there. 22 | 23 | Currently the extension syncs every 60 seconds, but I'll probably also make this configurable via the settings later. That means it could take up to one minute before the task count appears after setting the API token in the settings. 24 | -------------------------------------------------------------------------------- /prefs.js: -------------------------------------------------------------------------------- 1 | const GLib = imports.gi.GLib; 2 | const GObject = imports.gi.GObject; 3 | const Gio = imports.gi.Gio; 4 | const Gtk = imports.gi.Gtk; 5 | 6 | const Gettext = imports.gettext.domain('gnome-shell-extensions'); 7 | const _ = Gettext.gettext; 8 | 9 | const ExtensionUtils = imports.misc.extensionUtils; 10 | const Me = ExtensionUtils.getCurrentExtension(); 11 | const Convenience = Me.imports.convenience; 12 | 13 | function init() { 14 | } 15 | 16 | const TodoistPrefsWidget = new GObject.Class({ 17 | Name: 'Todoist.Prefs.Widget', 18 | GTypeName: 'TodoistPrefsWidget', 19 | Extends: Gtk.Grid, 20 | 21 | _init: function(params) { 22 | this.parent(params); 23 | this.margin = 12; 24 | this.row_spacing = this.column_spacing = 6; 25 | this.set_orientation(Gtk.Orientation.VERTICAL); 26 | 27 | this.add(new Gtk.Label({ label: '' + _("Todoist API token") + '', 28 | use_markup: true, 29 | halign: Gtk.Align.START })); 30 | 31 | let entry = new Gtk.Entry({ hexpand: true, 32 | margin_bottom: 12 }); 33 | this.add(entry); 34 | 35 | this._settings = Convenience.getSettings(); 36 | this._settings.bind('api-token', entry, 'text', Gio.SettingsBindFlags.DEFAULT); 37 | 38 | let primaryText = _("You need to declare a valid API token to allow this extension to communicate with the \ 39 | Todoist API on your behalf.\n\ 40 | You can find your personal API token on Todoist's integration settings page at the very bottom."); 41 | 42 | this.add(new Gtk.Label({ label: primaryText, 43 | wrap: true, xalign: 0 })); 44 | } 45 | }); 46 | 47 | function buildPrefsWidget() { 48 | let widget = new TodoistPrefsWidget(); 49 | widget.show_all(); 50 | 51 | return widget; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /convenience.js: -------------------------------------------------------------------------------- 1 | /* -*- mode: js; js-basic-offset: 4; indent-tabs-mode: nil -*- */ 2 | /* 3 | Copyright (c) 2011-2012, Giovanni Campagna 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | * Redistributions of source code must retain the above copyright 8 | notice, this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | * Neither the name of the GNOME nor the 13 | names of its contributors may be used to endorse or promote products 14 | derived from this software without specific prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 17 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 18 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY 20 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 21 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 22 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 23 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 24 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 25 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | */ 27 | 28 | const Gettext = imports.gettext; 29 | const Gio = imports.gi.Gio; 30 | 31 | const Config = imports.misc.config; 32 | const ExtensionUtils = imports.misc.extensionUtils; 33 | 34 | /** 35 | * initTranslations: 36 | * @domain: (optional): the gettext domain to use 37 | * 38 | * Initialize Gettext to load translations from extensionsdir/locale. 39 | * If @domain is not provided, it will be taken from metadata['gettext-domain'] 40 | */ 41 | function initTranslations(domain) { 42 | let extension = ExtensionUtils.getCurrentExtension(); 43 | 44 | domain = domain || extension.metadata['gettext-domain']; 45 | 46 | // check if this extension was built with "make zip-file", and thus 47 | // has the locale files in a subfolder 48 | // otherwise assume that extension has been installed in the 49 | // same prefix as gnome-shell 50 | let localeDir = extension.dir.get_child('locale'); 51 | if (localeDir.query_exists(null)) 52 | Gettext.bindtextdomain(domain, localeDir.get_path()); 53 | else 54 | Gettext.bindtextdomain(domain, Config.LOCALEDIR); 55 | } 56 | 57 | /** 58 | * getSettings: 59 | * @schema: (optional): the GSettings schema id 60 | * 61 | * Builds and return a GSettings schema for @schema, using schema files 62 | * in extensionsdir/schemas. If @schema is not provided, it is taken from 63 | * metadata['settings-schema']. 64 | */ 65 | function getSettings(schema) { 66 | let extension = ExtensionUtils.getCurrentExtension(); 67 | 68 | schema = schema || extension.metadata['settings-schema']; 69 | 70 | const GioSSS = Gio.SettingsSchemaSource; 71 | 72 | // check if this extension was built with "make zip-file", and thus 73 | // has the schema files in a subfolder 74 | // otherwise assume that extension has been installed in the 75 | // same prefix as gnome-shell (and therefore schemas are available 76 | // in the standard folders) 77 | let schemaDir = extension.dir.get_child('schemas'); 78 | let schemaSource; 79 | if (schemaDir.query_exists(null)) 80 | schemaSource = GioSSS.new_from_directory(schemaDir.get_path(), 81 | GioSSS.get_default(), 82 | false); 83 | else 84 | schemaSource = GioSSS.get_default(); 85 | 86 | let schemaObj = schemaSource.lookup(schema, true); 87 | if (!schemaObj) 88 | throw new Error('Schema ' + schema + ' could not be found for extension ' 89 | + extension.metadata.uuid + '. Please check your installation.'); 90 | 91 | return new Gio.Settings({ settings_schema: schemaObj }); 92 | } 93 | 94 | -------------------------------------------------------------------------------- /extension.js: -------------------------------------------------------------------------------- 1 | const St = imports.gi.St; 2 | const Main = imports.ui.main; 3 | const Soup = imports.gi.Soup; 4 | const Lang = imports.lang; 5 | const Mainloop = imports.mainloop; 6 | const Clutter = imports.gi.Clutter; 7 | const PanelMenu = imports.ui.panelMenu; 8 | const ExtensionUtils = imports.misc.extensionUtils; 9 | const Util = imports.misc.util; 10 | const Me = ExtensionUtils.getCurrentExtension(); 11 | const Convenience = Me.imports.convenience; 12 | const Polyfill = Me.imports.polyfill; 13 | const Utils = Me.imports.utils; 14 | 15 | const URL = 'https://todoist.com/API/v8/sync'; 16 | 17 | 18 | let _httpSession; 19 | let _syncToken; 20 | let _openItems; 21 | let _schema; 22 | 23 | const TodoistIndicator = new Lang.Class({ 24 | Name: 'Todoist.Indicator', 25 | Extends: PanelMenu.Button, 26 | 27 | _init: function () { 28 | this.parent(0.0, "Todoist Indicator", false); 29 | this.buttonText = new St.Label({ 30 | text: _("Loading..."), 31 | y_align: Clutter.ActorAlign.CENTER 32 | }); 33 | this.actor.add_actor(this.buttonText); 34 | this.actor.connect('button-press-event', _openWebpage); 35 | this._refresh(); 36 | }, 37 | 38 | _refresh: function () { 39 | this._loadData(this._refreshUI); 40 | this._removeTimeout(); 41 | this._timeout = Mainloop.timeout_add_seconds(60, Lang.bind(this, this._refresh)); 42 | return true; 43 | }, 44 | 45 | _loadData: function () { 46 | let token = _schema.get_string('api-token'); 47 | let params = { 48 | token: token, 49 | sync_token: _syncToken, 50 | resource_types: '["items"]' 51 | } 52 | _httpSession = new Soup.Session(); 53 | let message = Soup.form_request_new_from_hash('POST', URL, params); 54 | _httpSession.queue_message(message, Lang.bind(this, function (_httpSession, message) { 55 | if (message.status_code !== 200) 56 | return; 57 | let json = JSON.parse(message.response_body.data); 58 | this._refreshUI(json); 59 | } 60 | ) 61 | ); 62 | }, 63 | 64 | _isDoneOrDeletedOrArchived: function (item) { 65 | return item.checked === 1 || item.is_deleted === 1 || item.in_history === 1; 66 | }, 67 | 68 | _isNotDone: function (item) { 69 | return item.checked === 0; 70 | }, 71 | 72 | _extractId: function (item) { 73 | return item.id; 74 | }, 75 | 76 | _removeIfInList: function (item) { 77 | let index = _openItems.findIndex(openItem => openItem.id === item.id); 78 | if (index > -1) 79 | _openItems.splice(index, 1); 80 | }, 81 | 82 | _addOrUpdate: function (item) { 83 | let index = _openItems.findIndex(openItem => openItem.id === item.id); 84 | if (index === -1) 85 | _openItems.splice(_openItems.length, 0, item); 86 | else 87 | _openItems[index] = item 88 | }, 89 | 90 | _getTextForTaskCount: function (count) { 91 | switch (count) { 92 | case 0: return "no due tasks"; 93 | case 1: return "one due task"; 94 | default: return count + " due tasks"; 95 | } 96 | }, 97 | 98 | _parseJson: function (data) { 99 | _syncToken = data.sync_token; 100 | 101 | let undoneItems = data.items.filter(this._isNotDone); 102 | let doneItems = data.items.filter(this._isDoneOrDeletedOrArchived); 103 | undoneItems.forEach(this._addOrUpdate); 104 | doneItems.forEach(this._removeIfInList); 105 | }, 106 | 107 | _refreshUI: function (data) { 108 | this._parseJson(data); 109 | 110 | let count = _openItems.filter(Utils.isDueDateInPast).length; 111 | this.buttonText.set_text(this._getTextForTaskCount(count)); 112 | }, 113 | 114 | _removeTimeout: function () { 115 | if (this._timeout) { 116 | Mainloop.source_remove(this._timeout); 117 | this._timeout = null; 118 | } 119 | }, 120 | 121 | stop: function () { 122 | if (_httpSession !== undefined) 123 | _httpSession.abort(); 124 | _httpSession = undefined; 125 | 126 | if (this._timeout) 127 | Mainloop.source_remove(this._timeout); 128 | this._timeout = undefined; 129 | 130 | this.menu.removeAll(); 131 | } 132 | } 133 | ); 134 | 135 | function _openWebpage() { 136 | Util.spawn(['xdg-open', 'https://todoist.com/app#agenda%2Foverdue%2C%20today']) 137 | } 138 | 139 | let todoistMenu; 140 | 141 | function init() { 142 | _syncToken = '*'; 143 | _openItems = []; 144 | _schema = Convenience.getSettings(); 145 | } 146 | 147 | function enable() { 148 | todoistMenu = new TodoistIndicator; 149 | Main.panel.addToStatusArea('todoist-indicator', todoistMenu); 150 | } 151 | 152 | function disable() { 153 | todoistMenu.stop(); 154 | todoistMenu.destroy(); 155 | } 156 | --------------------------------------------------------------------------------