├── .gitignore ├── .rspec ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── fixtures ├── .gitignore ├── uuid │ └── .gitignore └── vcr_cassettes │ └── .gitignore ├── lib ├── todoist.rb └── todoist │ ├── client.rb │ ├── config.rb │ ├── misc │ ├── activity.rb │ ├── backups.rb │ ├── completed.rb │ ├── items.rb │ ├── projects.rb │ ├── quick.rb │ ├── templates.rb │ └── uploads.rb │ ├── service.rb │ ├── sync │ ├── filters.rb │ ├── items.rb │ ├── labels.rb │ ├── notes.rb │ ├── projects.rb │ └── reminders.rb │ ├── util │ ├── api_helper.rb │ ├── network_helper.rb │ ├── parse_helper.rb │ └── uuid.rb │ └── version.rb ├── spec ├── filters_spec.rb ├── items_spec.rb ├── labels_spec.rb ├── misc_activity_spec.rb ├── misc_backups_spec.rb ├── misc_completed_spec.rb ├── misc_items_spec.rb ├── misc_projects_spec.rb ├── misc_quick.rb ├── misc_templates_spec.rb ├── misc_uploads_spec.rb ├── notes_spec.rb ├── projects_spec.rb ├── reminders_spec.rb ├── spec_helper.rb └── template_sample.csv └── todoist.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | coverage 11 | /spec/token -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.3.3 5 | before_install: gem install bundler -v 1.13.7 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in todoist.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Han Yuan 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todoist Ruby 2 | 3 | This is an unofficial client library that interfaces with the [Todoist API](https://developer.todoist.com/sync/v8/). 4 | 5 | ## What's implemented 6 | 7 | ### Sync API 8 | 9 | The "sync" API is almost fully implemented with the exception of collaboration features. 10 | 11 | * [Projects](https://developer.todoist.com/sync/v8/#projects) 12 | * [Templates](https://developer.todoist.com/sync/v8/#templates) 13 | * [Items](https://developer.todoist.com/sync/v8/#items) 14 | * [Labels](https://developer.todoist.com/sync/v8/#labels) 15 | * [Notes](https://developer.todoist.com/sync/v8/#notes) 16 | * [Filters](https://developer.todoist.com/sync/v8/#filters) 17 | * [Reminders](https://developer.todoist.com/sync/v8/#reminders) 18 | 19 | ### Other APIs 20 | 21 | * [Miscellaneous](https://developer.todoist.com/sync/v8/#miscellaneous) 22 | * [Quick](https://developer.todoist.com/sync/v8/#quick) 23 | * [Activity](https://developer.todoist.com/sync/v8/#activity) 24 | * [Uploads](https://developer.todoist.com/sync/v8/#uploads) 25 | * [Backups](https://developer.todoist.com/sync/v8/#backups) 26 | 27 | In addition to the above mentioned APIs, there is also an implementation of the "query" method call provided (with limitations documented). 28 | 29 | ## What's not implemented 30 | 31 | Generally speaking collaboration features are not supported through this API but contributions are welcome and encouraged primarily due to testing limitations and the requirement to have multiple accounts. This includes: 32 | 33 | * [Emails](https://developer.todoist.com/sync/v8/#emails) 34 | * [User](https://developer.todoist.com/sync/v8/#user) 35 | * [Sharing](https://developer.todoist.com/sync/v8/#sharing) 36 | * [Live notifications](https://developer.todoist.com/sync/v8/#live-notifications) 37 | 38 | ## Installation 39 | 40 | Add this line to your application's Gemfile: 41 | 42 | ```ruby 43 | gem 'todoist-ruby' 44 | ``` 45 | 46 | or install from source 47 | 48 | ```ruby 49 | gem "todoist-ruby", :git => "git://github.com/h6y3/todoist-ruby.git" 50 | ``` 51 | 52 | And then execute: 53 | 54 | $ bundle 55 | 56 | Or install it yourself as: 57 | 58 | $ gem install todoist-ruby 59 | 60 | ## Usage 61 | 62 | This section provides some simple scenarios to get started. To use the library make sure you include the library as follows: 63 | 64 | ```ruby 65 | require 'todoist' 66 | ``` 67 | 68 | ### Logging in and setting tokens 69 | 70 | Before you make any API calls, you **must** create a client using one of two methods. The library supports two methods: 71 | 72 | #### Email and password 73 | 74 | ```ruby 75 | @client = Todoist::Client.create_client_by_login("hello@example.com", "123") 76 | ``` 77 | 78 | #### Token 79 | 80 | New tokens can be generated at the [Todoist App Management portal](https://developer.todoist.com/appconsole.html) or [General API token](https://todoist.com/prefs/integrations). Once a token has been acquired you can create a client by calling: 81 | 82 | ```ruby 83 | @client = Todoist::Client.create_client_by_token("my token") 84 | ``` 85 | 86 | ### Using the API 87 | 88 | The Todoist API enables you to mimic how the actual Todoist client retrieves information. Among other nuances, the "sync" API minimizes network traffic by batching a series of calls together. It supports dependencies between as-yet-created objects through temporary IDs. In addition to the sync API, Todoist also has several other methods that are lighterweight. For many light use cases, the lightweight methods will suffice but for more complex cases you will likely need to use both approaches. 89 | 90 | All APIs can be accessed through the client. In general, the naming convention to access the service is ```[api_type]_[api]```. 91 | 92 | There are two ways to force a sync in the API: 93 | 94 | 1. ```collection```: Calling collection forces the library to sync with the Todoist server to retrieve the latest. This method stores an internal in-memory copy of the result for incremental syncs but the caller should store a copy of the result in its own variable for query purposes. The following call syncs all items and returns a collection of the items: ```@client.sync_items.collection```. 95 | 2. ```sync```: Calling the sync method on the client object forces a sync which can be called like ```@client.sync``` 96 | 97 | When objects are called using the ```add``` methods, a shallow object is created with a temporary id accessible by sending an ```id``` message. Once any of the above synchronization methods are called above, the ids are updated via a callback with their actual ids so they can be used in subsequent calls. 98 | 99 | #### Creating an item 100 | 101 | ```ruby 102 | update_item = @client.sync_items.add({content: "Item3"}) 103 | ## At this time update_item has a temporary id 104 | 105 | update_item.priority = 2 106 | result = @client.sync_items.update(update_item) 107 | # Up until this point update_item has not been created yet 108 | 109 | items_list = @client.sync_items.collection 110 | # Update item is created and a query is issued to sync up the existing items. The actual id of the newly created item is updated and so now update_item should have the actual id. 111 | 112 | queried_object = items_list[update_item.id] 113 | # update_item remains a shallow value object. To fully inflate the object, you will need to retrieve the item from the list. At this point, queried_object has a fully inflated copy of the object 114 | 115 | @client.sync_items.delete([update_item]) 116 | # As is the case with other side-effects, issuing the call does not send the request immediately. 117 | 118 | @client.sync 119 | # Manually calling sync deletes the item 120 | ``` 121 | 122 | For more detailed examples, please review the unit tests located in the ```spec``` directory. 123 | 124 | ### Other APIs 125 | 126 | The rest of the APIs are available in the ```Todoist::Misc``` module. For lighterweight use cases, there are several interesting APIs of interest. See Todoist documentation linked above. 127 | 128 | #### Creating an item using the "quick" API 129 | 130 | ```ruby 131 | item = @client.misc_quick.add_item("Test quick add content today") 132 | # Unlike the sync API the item is already created after this method call and fully inflated 133 | ``` 134 | ### Rate limiting 135 | 136 | According to the Todoist API documentation, the following limitations exist: 137 | 138 | The maximum number of commands is 100 per request, and this is done to prevent timeouts and other problems when dealing with big requests. 139 | 140 | There’s also a maximum number of 50 sync requests per minute for each user, in order to prevent clients from accidentally overloading our servers. 141 | 142 | In practice, the rate limit is much more aggressive than 50 sync requests per minute as far as I can tell. Because of this, the unit tests make use of [vcr](https://github.com/vcr/vcr) to cache HTTP requests. While it is unlikely clients will hit the rate limit besides unit test scenarios, it can be possible. 143 | 144 | The library provides two defenses against this. 145 | 146 | #### HTTP 429 Protection 147 | If an ```HTTP 429 Too Many Requests``` is received, the library can wait for a period of time and then retry with an exponential backoff. To configure this parameter: 148 | 149 | ```ruby 150 | Todoist::Config.retry_time = 40 151 | # Default is 20s, adds a 40s delay 152 | ``` 153 | 154 | #### Delay between sync requests 155 | 156 | To set an artifical delay between sync requests: 157 | 158 | ```ruby 159 | Todoist::Config.delay_between_requests = 2 160 | # Default is 0, adds a 2s delay 161 | ``` 162 | 163 | 164 | ## Development 165 | 166 | After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 167 | 168 | To run the unit tests, a development token is needed and should be stored on the first line of a file located at ```spec/token```. 169 | 170 | Due to rate limiting, running ```rake``` will result in HTTP 429 codes. Instead, it is recommended that tests be run individually for the area that you wish to develop: 171 | 172 | ``` 173 | rspec spec/misc_items_spec.rb 174 | ``` 175 | 176 | The unit tests generate a set of 100 UUIDs for both temporary ids and command_uuids. This is done so that when the VCR gem records the user interaction, the HTTP requests match. When there are bugs in the test that propogate to problematic network calls, you will need to clean both the generated UUIDs and the VCR recordings. To do this, a rake command is provided: 177 | 178 | ``` 179 | rake spec:clean["misc_items"] 180 | # Cleans up VCR and UUIDs for artifacts with the "misc_items" prefix 181 | ``` 182 | 183 | Once tests pass cleanly, subsquent runs that do not change the network requests run quickly since no network calls are made and in fact ```rake``` can be run with no issues. 184 | 185 | 186 | ## Migration 187 | 188 | ### Migrating from 1.x to 2.x 189 | 190 | 2.x was a major rewrite of the library that avoids using classes as Singletons. In addition, in 2.x the client facing interfaces have been greatly simplified. 191 | 192 | ### Authentication 193 | 194 | Instead of: 195 | ``` 196 | Todoist::Config.token = "my token" 197 | ``` 198 | 199 | Use: 200 | 201 | ``` 202 | @client = Todoist::Client.create_client_by_token("my token") 203 | ``` 204 | 205 | ### Calling Services 206 | 207 | Instead of: 208 | 209 | ``` 210 | @manager = Todoist::Sync::Items.new 211 | @manager.collection 212 | ``` 213 | 214 | Use: 215 | ``` 216 | @client.sync_items.collection 217 | ``` 218 | 219 | ### Synchronization 220 | 221 | Instead of: 222 | 223 | ``` 224 | Todoist::Util::CommandSynchronizer.sync 225 | ``` 226 | 227 | Use: 228 | 229 | ``` 230 | @client.sync 231 | ``` 232 | 233 | 234 | ## Version History 235 | * 0.2.6: Fixed File.exists? to File.exist. Updated deletion API call for items and projects with updated tests. Special thank you to @mtantawy for the pull request and updates! 236 | * 0.2.5: Fix for Extra params aren't sent when creating a item when using the misc add_item method. (Thank you to @juliend2) 237 | * 0.2.4: Numerous bug fixes to address v7 to v8 changes that go beyond just an endpoint change. Passing all specs. 238 | * 0.2.3: Updated to v8 endpoints 239 | * 0.2.2: For some code paths, it seems OpenSSL does not get loaded. Added require 'openssl' to network helper 240 | * 0.2.1: Major refactoring of library to support implementations that require multi-user support in a concurrent environment (e.g. Rails app). The previous implementation relied heavily on class singletons. Internally, the code has been cleaned up significantly. Due to the scale of changes, 0.2.1 is not compatible 0.1.x versions of the library. 241 | * 0.1.3: Changed ```Todoist::Sync``` managers so that the update method uses a hash instead of an OpenStruct. The OpenStruct creates errors when an OpenStruct passed from a previous call is used. The hash helps the caller make fewer mistakes. 242 | * 0.1.2: Renamed method ```Todoist::Util::ParseHelper.make_objects_as_array``` to ```Todoist::Util::ParseHelper.make_objects_as_hash``` to reflect the fact that it was actually returning hashes. Added the aforementioned deleted method to return arrays and finally altered ```Todoist::Misc::Completed``` to return objects as arrays instead of hashes due to the fact that recurring completed items were being de-duped unintentionally and data was being lost as a result. 243 | * 0.1.1: Initial release. 244 | 245 | ## Contributing 246 | 247 | Bug reports and pull requests are welcome on GitHub at https://github.com/h6y3/todoist-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 248 | 249 | 250 | ## License 251 | 252 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 253 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | namespace :spec do 9 | desc "Delete VCR and pre-generated UUID files" 10 | task :clean_all do 11 | FileUtils.rm Dir.glob('fixtures/uuid/*.yml') 12 | FileUtils.rm Dir.glob('fixtures/vcr_cassettes/*.yml') 13 | end 14 | desc "Delete VCR and pre-generated UUID files for a specific resource" 15 | task :clean, [:resource] do |t, args| 16 | FileUtils.rm Dir.glob("fixtures/uuid/#{args[:resource]}*.yml") 17 | FileUtils.rm Dir.glob("fixtures/vcr_cassettes/#{args[:resource]}*.yml") 18 | end 19 | end 20 | 21 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "todoist" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /fixtures/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore 5 | !uuid 6 | !vcr_cassettes -------------------------------------------------------------------------------- /fixtures/uuid/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore -------------------------------------------------------------------------------- /fixtures/vcr_cassettes/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore everything in this directory 2 | * 3 | # Except this file 4 | !.gitignore -------------------------------------------------------------------------------- /lib/todoist.rb: -------------------------------------------------------------------------------- 1 | require "todoist/version" 2 | require "todoist/config" 3 | require "todoist/util/api_helper" 4 | require "todoist/util/network_helper" 5 | require "todoist/service" 6 | require "todoist/client" 7 | 8 | require "todoist/sync/items" 9 | require "todoist/sync/labels" 10 | require "todoist/sync/projects" 11 | require "todoist/sync/notes" 12 | require "todoist/sync/reminders" 13 | require "todoist/sync/filters" 14 | require "todoist/misc/templates" 15 | require "todoist/misc/uploads" 16 | require "todoist/misc/completed" 17 | require "todoist/misc/projects" 18 | require "todoist/misc/items" 19 | require "todoist/misc/quick" 20 | require "todoist/misc/activity" 21 | require "todoist/misc/backups" 22 | 23 | module Todoist 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/todoist/client.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | class Client 3 | 4 | def self.create_client_by_token(token) 5 | client = Client.new 6 | client.token = token 7 | client 8 | end 9 | 10 | # TODO: Need to write a unit test for this 11 | def self.create_client_by_login(email, password) 12 | client = Client.new 13 | result = client.api_helper.get_response(Config::TODOIST_USER_LOGIN_COMMAND, {email: email, password: password}, false) 14 | user = Todoist::Util::ParseHelper.make_object(result) 15 | client.token = user.token 16 | client 17 | end 18 | 19 | def token=(token) 20 | @token = token.chomp 21 | end 22 | 23 | def token 24 | @token 25 | end 26 | 27 | def sync 28 | @api_helper.sync 29 | end 30 | 31 | def misc_activity 32 | @misc_activity = Todoist::Misc::Activity.new(self) unless @misc_activity 33 | @misc_activity 34 | end 35 | 36 | def misc_backups 37 | @misc_backups = Todoist::Misc::Backups.new(self) unless @misc_backups 38 | @misc_backups 39 | end 40 | 41 | def misc_completed 42 | @misc_completed = Todoist::Misc::Completed.new(self) unless @misc_completed 43 | @misc_completed 44 | end 45 | 46 | def misc_items 47 | @misc_items = Todoist::Misc::Items.new(self) unless @misc_items 48 | @misc_items 49 | end 50 | 51 | def misc_projects 52 | @misc_projects = Todoist::Misc::Projects.new(self) unless @misc_projects 53 | @misc_projects 54 | end 55 | 56 | def misc_query 57 | @misc_query = Todoist::Misc::Query.new(self) unless @misc_query 58 | @misc_query 59 | end 60 | 61 | def misc_quick 62 | @misc_quick = Todoist::Misc::Quick.new(self) unless @misc_quick 63 | @misc_quick 64 | end 65 | 66 | def misc_templates 67 | @misc_templates = Todoist::Misc::Templates.new(self) unless @misc_templates 68 | @misc_templates 69 | end 70 | 71 | def misc_uploads 72 | @misc_uploads = Todoist::Misc::Uploads.new(self) unless @misc_uploads 73 | @misc_uploads 74 | end 75 | 76 | def sync_filters 77 | @sync_filters = Todoist::Sync::Filters.new(self) unless @sync_filters 78 | @sync_filters 79 | end 80 | 81 | def sync_items 82 | @sync_items = Todoist::Sync::Items.new(self) unless @sync_items 83 | @sync_items 84 | end 85 | 86 | def sync_labels 87 | @sync_labels = Todoist::Sync::Labels.new(self) unless @sync_labels 88 | @sync_labels 89 | end 90 | 91 | def sync_notes 92 | @sync_notes = Todoist::Sync::Notes.new(self) unless @sync_notes 93 | @sync_notes 94 | end 95 | 96 | def sync_projects 97 | @sync_projects = Todoist::Sync::Projects.new(self) unless @sync_projects 98 | @sync_projects 99 | end 100 | 101 | def sync_reminders 102 | @sync_reminders = Todoist::Sync::Reminders.new(self) unless @sync_reminders 103 | @sync_reminders 104 | end 105 | 106 | def api_helper 107 | @api_helper 108 | end 109 | 110 | protected 111 | 112 | def initialize 113 | @api_helper = Todoist::Util::ApiHelper.new(self) 114 | end 115 | 116 | 117 | 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/todoist/config.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | class Config 3 | TODOIST_API_URL = "https://api.todoist.com/sync/v9" 4 | 5 | # List of commands supported 6 | @@command_list = [ 7 | TODOIST_SYNC_COMMAND = "/sync", 8 | TODOIST_TEMPLATES_IMPORT_INTO_PROJECT_COMMAND = "/templates/import_into_project", 9 | TODOIST_TEMPLATES_EXPORT_AS_FILE_COMMAND = "/templates/export_as_file", 10 | TODOIST_TEMPLATES_EXPORT_AS_URL_COMMAND = "/templates/export_as_url", 11 | TODOIST_UPLOADS_ADD_COMMAND = "/uploads/add", 12 | TODOIST_UPLOADS_GET_COMMAND = "/uploads/get", 13 | TODOIST_UPLOADS_DELETE_COMMAND = "/uploads/delete", 14 | TODOIST_COMPLETED_GET_STATS_COMMAND = "/completed/get_stats", 15 | TODOIST_COMPLETED_GET_ALL_COMMAND = "/completed/get_all", 16 | TODOIST_PROJECTS_GET_ARCHIVED_COMMAND = "/projects/get_archived", 17 | TODOIST_PROJECTS_GET_COMMAND = "/projects/get", 18 | TODOIST_PROJECTS_GET_DATA_COMMAND = "/projects/get_data", 19 | TODOIST_ITEMS_ADD_COMMAND = "/items/add", 20 | TODOIST_ITEMS_GET_COMMAND = "/items/get", 21 | TODOIST_QUICK_ADD_COMMAND = "/quick/add", 22 | TODOIST_ACTIVITY_GET_COMMAND = "/activity/get", 23 | TODOIST_BACKUPS_GET_COMMAND = "/backups/get", 24 | TODOIST_USER_LOGIN_COMMAND = "/user/login" 25 | ] 26 | 27 | # Map of commands to URIs 28 | @@uri = nil 29 | 30 | # Artificial delay between requests to avoid API throttling 31 | @@delay_between_requests = 0 32 | 33 | # Should API throttling happen (HTTP Error 429), retry_time between requests 34 | # with exponential backoff 35 | @@retry_time = 20 36 | 37 | def self.retry_time=(retry_time) 38 | @@retry_time = retry_time 39 | end 40 | 41 | def self.retry_time 42 | @@retry_time 43 | end 44 | 45 | def self.delay_between_requests=(delay_between_requests) 46 | @@delay_between_requests = delay_between_requests 47 | end 48 | 49 | def self.delay_between_requests 50 | @@delay_between_requests 51 | end 52 | 53 | def self.getURI 54 | if @@uri == nil 55 | @@uri = {} 56 | @@command_list.each do |command| 57 | @@uri[command] = URI.parse(TODOIST_API_URL + command) 58 | end 59 | end 60 | return @@uri 61 | end 62 | 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/todoist/misc/activity.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Activity < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Returns the activity logs for a user. 7 | 8 | def get(params={}) 9 | if params["until"] 10 | params["until"] = ParseHelper.format_time(params["until"]) 11 | end 12 | 13 | if params["since"] 14 | params["since"] = ParseHelper.format_time(params["since"]) 15 | end 16 | 17 | if params["object"] 18 | params["object_id"] = params["object"].id 19 | params.delete("object") 20 | end 21 | 22 | if params["parent_object"] 23 | params["parent_object_id"] = params["parent_object"].id 24 | params.delete("parent_object") 25 | end 26 | 27 | if params["parent_item"] 28 | params["parent_item_id"] = params["parent_item"].id 29 | params.delete("parent_item") 30 | end 31 | 32 | if params["initiator"] 33 | params["initiator_id"] = params["initiator"].id 34 | params.delete("initiator") 35 | end 36 | 37 | result = @client.api_helper.get_response(Config::TODOIST_ACTIVITY_GET_COMMAND, params) 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/todoist/misc/backups.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Backups < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Returns the backups for a user. 7 | def get() 8 | result = @client.api_helper.get_response(Config::TODOIST_BACKUPS_GET_COMMAND, {}) 9 | ParseHelper.make_objects_as_hash(result) 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/todoist/misc/completed.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Completed < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Get productivity stats. Returns a hash of statistics as documented 7 | # at https://developer.todoist.com/#get-productivity-stats 8 | def get_productivity_stats() 9 | @client.api_helper.get_response(Config::TODOIST_COMPLETED_GET_STATS_COMMAND, {}) 10 | end 11 | 12 | # Retrieves all completed items as documented at 13 | # https://developer.todoist.com/#get-all-completed-items. Several parameters 14 | # are possible to limit scope. See link. Dates should be passed 15 | # as DateTime. This method takes care of the formatting to send to the 16 | # API. Returns projects and items back as :items and :projects keys. 17 | 18 | def get_all_completed_items(params = {}) 19 | if params["until"] 20 | params["until"] = ParseHelper.format_time(params["until"]) 21 | end 22 | if params["since"] 23 | params["since"] = ParseHelper.format_time(params["since"]) 24 | end 25 | 26 | result = @client.api_helper.get_response(Config::TODOIST_COMPLETED_GET_ALL_COMMAND, params) 27 | items = ParseHelper.make_objects_as_array(result["items"]) 28 | projects = ParseHelper.make_objects_as_array(result["projects"]) 29 | return {"items" => items, "projects" => projects} 30 | end 31 | 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/todoist/misc/items.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Items < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Add a new task to a project. Note, that this is provided as a 7 | # helper method, a shortcut, to quickly add a task without going 8 | # through the Sync workflow. 9 | # This method takes content as well as an array of optional params as 10 | # detailed here: https://developer.todoist.com/#add-item. 11 | # 12 | # If adding labels, use the key "labels" and input an array of label 13 | # objects. For project, add a project object. 14 | # 15 | # Critically, collaboration features are not supported by this library 16 | # so assigned_by_uid and responsible_uid are not implemented. 17 | 18 | def add_item(content, optional_params = {}) 19 | params = {content: content} 20 | if optional_params["project"] 21 | params["project_id"] = project.id 22 | optional_params.delete("project") 23 | end 24 | 25 | if optional_params["labels"] 26 | labels = optional_params["labels"] 27 | labels_param = labels.collect { |label| label.id } 28 | params["labels"] = labels_param.to_json 29 | optional_params.delete("labels") 30 | end 31 | 32 | params.merge!(optional_params) 33 | result = @client.api_helper.get_response(Config::TODOIST_ITEMS_ADD_COMMAND, params) 34 | item = ParseHelper.make_object(result) 35 | return item 36 | end 37 | 38 | # This function is used to extract detailed information about the item, 39 | # including all the notes. It’s especially important, because on initial 40 | # load we return back no more than 10 last notes. 41 | # 42 | # For more information see: https://developer.todoist.com/#get-item-info 43 | def get_item(item, all_data = true) 44 | params = {item_id: item.id, all_data: all_data} 45 | 46 | result = @client.api_helper.get_response(Config::TODOIST_ITEMS_GET_COMMAND, params) 47 | item = ParseHelper.make_object(result["item"]) 48 | project = ParseHelper.make_object(result["project"]) 49 | notes = result["notes"] ? ParseHelper.make_objects_as_hash(result["notes"]) : nil 50 | return {"item" => item, "project" => project, "notes" => notes} 51 | end 52 | 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/todoist/misc/projects.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Projects < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Get archived projects. Returns projects as documented here. 7 | def get_archived_projects() 8 | result = @client.api_helper.get_response(Config::TODOIST_PROJECTS_GET_ARCHIVED_COMMAND) 9 | return ParseHelper.make_objects_as_hash(result) 10 | end 11 | 12 | # Gets project information including all notes. 13 | 14 | def get_project_info(project, all_data = true) 15 | result = @client.api_helper.get_response(Config::TODOIST_PROJECTS_GET_COMMAND, {project_id: project.id, all_data: true}) 16 | 17 | project = result["project"] ? ParseHelper.make_object(result["project"]) : nil 18 | notes = result["notes"] ? ParseHelper.make_objects_as_hash(result["notes"]) : nil 19 | return {"project" => project, "notes" => notes} 20 | end 21 | 22 | # Gets a project's uncompleted items 23 | def get_project_data(project) 24 | result = @client.api_helper.get_response(Config::TODOIST_PROJECTS_GET_DATA_COMMAND, {project_id: project.id}) 25 | project = result["project"] ? ParseHelper.make_object(result["project"]) : nil 26 | items = result["items"] ? ParseHelper.make_objects_as_hash(result["items"]) : nil 27 | return {"project" => project, "items" => items} 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/todoist/misc/quick.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Quick < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Implementation of the Quick Add Task available in the official 7 | # clients. 8 | def add_item(text) 9 | result = @client.api_helper.get_response(Config::TODOIST_QUICK_ADD_COMMAND, {text: text}) 10 | return ParseHelper.make_object(result) 11 | end 12 | 13 | end 14 | end 15 | end -------------------------------------------------------------------------------- /lib/todoist/misc/templates.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Templates < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Given a project and a File object (Ruby) imports the content onto the 7 | # server. Critically, if the file is a CSV file make sure that the 8 | # suffix is CSV. Otherwise, the file will be parsed as one item per line 9 | # and ignore the formatting altogether. 10 | def import_into_project(project, file) 11 | multipart_file = @client.api_helper.multipart_file(file) 12 | params = {project_id: project.id, file: multipart_file} 13 | @client.api_helper.get_multipart_response(Config::TODOIST_TEMPLATES_IMPORT_INTO_PROJECT_COMMAND, params) 14 | end 15 | 16 | # Export the project as a CSV string 17 | def export_as_file(project) 18 | params = {project_id: project.id} 19 | @client.api_helper.get_response(Config::TODOIST_TEMPLATES_EXPORT_AS_FILE_COMMAND, params) 20 | end 21 | 22 | # Export the project as a url that can be accessed over HTTP 23 | def export_as_url(project) 24 | params = {project_id: project.id} 25 | @client.api_helper.get_response(Config::TODOIST_TEMPLATES_EXPORT_AS_URL_COMMAND, params) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/todoist/misc/uploads.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Misc 3 | class Uploads < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Uploads a file given a Ruby File. 7 | def add(file) 8 | multipart_file = @client.api_helper.multipart_file(file) 9 | params = {file_name: File.basename(file), file: multipart_file} 10 | @client.api_helper.get_multipart_response(Config::TODOIST_UPLOADS_ADD_COMMAND, params) 11 | end 12 | 13 | # Get uploads up to limit. If last_id is entered, then the results list 14 | # everything from that ID forward. 15 | def get(limit = 30, last_id = 0) 16 | params = {limit: limit} 17 | params["last_id"] = last_id if last_id 18 | @client.api_helper.get_response(Config::TODOIST_UPLOADS_GET_COMMAND, params) 19 | end 20 | 21 | # Deletes an upload given a file URL. 22 | def delete(file_url) 23 | params = {file_url: file_url} 24 | @client.api_helper.get_response(Config::TODOIST_UPLOADS_DELETE_COMMAND, params) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/todoist/service.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | class Service 3 | include Todoist::Util 4 | 5 | def initialize(client) 6 | @client = client 7 | end 8 | 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/todoist/sync/filters.rb: -------------------------------------------------------------------------------- 1 | 2 | 3 | module Todoist 4 | module Sync 5 | class Filters < Todoist::Service 6 | include Todoist::Util 7 | 8 | # Return a Hash of filters where key is the id of a filter and value is a filter 9 | def collection 10 | return @client.api_helper.collection("filters") 11 | end 12 | 13 | # Add a filter with a given hash of attributes and returns the filter id. 14 | # Please note that item_id is required as is a date as specific in the 15 | # documentation. This method can be tricky to all. 16 | def add(args) 17 | return @client.api_helper.add(args, "filter_add") 18 | end 19 | 20 | # Update a filter given a hash of attributes 21 | def update(args) 22 | return @client.api_helper.command(args, "filter_update") 23 | end 24 | 25 | # Delete filter given an array of filters 26 | def delete(filter) 27 | args = {id: filter.id} 28 | return @client.api_helper.command(args, "filter_delete") 29 | end 30 | 31 | # Update orders for an array of filters 32 | def update_multiple_orders(filters) 33 | args = {} 34 | filters.each do |filter| 35 | args[filter.id] = filter.item_order 36 | end 37 | args = {id_order_mapping: args.to_json} 38 | return @client.api_helper.command(args, "filter_update_orders") 39 | end 40 | 41 | 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/todoist/sync/items.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Sync 3 | class Items < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Return a Hash of items where key is the id of a item and value is a item 7 | def collection 8 | return @client.api_helper.collection("items") 9 | end 10 | 11 | # Add a item with a given hash of attributes and returns the item id 12 | def add(args) 13 | return @client.api_helper.add(args, "item_add") 14 | end 15 | 16 | # Update item given a hash of attributes 17 | def update(args) 18 | return @client.api_helper.command(args, "item_update") 19 | end 20 | 21 | # Delete an item given an item id 22 | def delete(item_id) 23 | args = {id: item_id} 24 | return @client.api_helper.command(args, "item_delete") 25 | end 26 | 27 | # Move an item from one project to another project given an item and a project. 28 | # Note that move requires a fully inflated item object because it uses 29 | # the project id in the item object. 30 | def move(item, project) 31 | args = {id: item.id, project_id: project.id} 32 | return @client.api_helper.command(args, "item_move") 33 | end 34 | 35 | # Complete an item and optionally move them to history. When force_history = 1, items should be moved to history (where 1 is true and 0 is false, and the default is 1) This is useful when checking off sub items. 36 | 37 | def complete(item, force_history=1) 38 | args = {id: item.id, force_history: force_history} 39 | return @client.api_helper.command(args, "item_complete") 40 | end 41 | 42 | # Uncomplete item and move them to the active projects 43 | 44 | def uncomplete(item) 45 | args = {id: item.id} 46 | return @client.api_helper.command(args, "item_uncomplete") 47 | end 48 | 49 | # Complete a recurring item given the id of the recurring item. 50 | # This method also accepts as optional a new DateTime in UTC, a date 51 | # string to reset the object to, and whether or not the item is to 52 | # be completed or not using the is_forward flag. 53 | 54 | def complete_recurring(item, new_date_utc = nil, date_string = nil, 55 | is_forward = 1) 56 | 57 | args = {id: item.id, is_forward: is_forward} 58 | if new_date_utc 59 | # Reformat DateTime to the following string: YYYY-MM-DDTHH:MM 60 | args["due"] = {date: ParseHelper.format_time(new_date_utc)} 61 | end 62 | 63 | if date_string 64 | args["due"] = {string: date_string} 65 | end 66 | 67 | return @client.api_helper.command(args, "item_update_date_complete") 68 | end 69 | 70 | # A simplified version of item_complete / item_update_date_complete. 71 | # The command does exactly what official clients do when you close a item 72 | # given an item. 73 | 74 | def close(item) 75 | args = {id: item.id} 76 | return @client.api_helper.command(args, "item_close") 77 | end 78 | 79 | # Update the day orders of multiple items at once given an array of 80 | # items 81 | def update_day_orders(items) 82 | ids_to_orders = {} 83 | items.each do |item| 84 | ids_to_orders[item.id] = item.day_order 85 | end 86 | args = {ids_to_orders: ids_to_orders.to_json} 87 | return @client.api_helper.command(args, "item_update_day_orders") 88 | end 89 | 90 | # Update orders and indents for an array of items 91 | def update_multiple_orders_and_indents(items) 92 | tuples = {} 93 | items.each do |item| 94 | tuples[item.id] = [item.item_order, item.indent] 95 | end 96 | args = {ids_to_orders_indents: tuples.to_json} 97 | return @client.api_helper.command(args, "item_update_orders_indents") 98 | end 99 | 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/todoist/sync/labels.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Sync 3 | class Labels < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Return a Hash of labels where key is the id of a label and value is a label 7 | def collection 8 | return @client.api_helper.collection("labels") 9 | end 10 | 11 | # Add a label with a given hash of attributes and returns the label id 12 | def add(args) 13 | return @client.api_helper.add(args, "label_add") 14 | end 15 | 16 | # Update label given a hash of attributes 17 | def update(args) 18 | return @client.api_helper.command(args, "label_update") 19 | end 20 | 21 | # Delete a label given a label 22 | def delete(label) 23 | args = {id: label.id} 24 | return @client.api_helper.command(args, "label_delete") 25 | end 26 | 27 | # Update orders for an array of labels 28 | def update_multiple_orders(labels) 29 | args = {} 30 | labels.each do |label| 31 | args[label.id] = label.item_order 32 | end 33 | args = {id_order_mapping: args.to_json} 34 | return @client.api_helper.command(args, "label_update_orders") 35 | end 36 | 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/todoist/sync/notes.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Sync 3 | class Notes < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Return a Hash of notes where key is the id of a note and value is a note 7 | def collection 8 | return @client.api_helper.collection("notes") 9 | end 10 | 11 | # Add a note with a given hash of attributes and returns the note id. 12 | # Please note that item_id or project_id key is required. In addition, 13 | # content is also a required key in the hash. 14 | def add(args) 15 | return @client.api_helper.add(args, "note_add") 16 | end 17 | 18 | # Update a note given a hash of attributes 19 | def update(args) 20 | return @client.api_helper.command(args, "note_update") 21 | end 22 | 23 | # Delete notes given an a note 24 | def delete(note) 25 | args = {id: note.id} 26 | return @client.api_helper.command(args, "note_delete") 27 | end 28 | 29 | 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/todoist/sync/projects.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Sync 3 | 4 | 5 | class Projects < Todoist::Service 6 | include Todoist::Util 7 | 8 | # Return a Hash of projects where key is the id of a project and value is a project 9 | def collection 10 | return @client.api_helper.collection("projects") 11 | end 12 | 13 | # Add a project with a given hash of attributes and returns the project id 14 | def add(args) 15 | return @client.api_helper.add(args, "project_add") 16 | end 17 | 18 | # Delete a project given a project id 19 | def delete(project_id) 20 | args = {id: project_id} 21 | return @client.api_helper.command(args, "project_delete") 22 | end 23 | 24 | # Archive projects given an array of projects 25 | def archive(projects) 26 | project_ids = projects.collect { |project| project.id } 27 | args = {ids: project_ids.to_json} 28 | return @client.api_helper.command(args, "project_archive") 29 | end 30 | 31 | # Unarchive projects given an array of projects 32 | def unarchive(projects) 33 | project_ids = projects.collect { |project| project.id } 34 | args = {ids: project_ids.to_json} 35 | return @client.api_helper.command(args, "project_unarchive") 36 | end 37 | 38 | # Update project given a hash of attributes 39 | def update(args) 40 | return @client.api_helper.command(args, "project_update") 41 | end 42 | 43 | # Update orders and indents for an array of projects 44 | def update_multiple_orders_and_indents(projects) 45 | tuples = {} 46 | projects.each do |project| 47 | tuples[project.id] = [project.item_order, project.indent] 48 | end 49 | args = {ids_to_orders_indents: tuples.to_json} 50 | return @client.api_helper.command(args, "project_update_orders_indents") 51 | end 52 | 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /lib/todoist/sync/reminders.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | module Sync 3 | class Reminders < Todoist::Service 4 | include Todoist::Util 5 | 6 | # Return a Hash of reminders where key is the id of a reminder and value is a reminder 7 | def collection 8 | return @client.api_helper.collection("reminders") 9 | end 10 | 11 | # Add a reminder with a given hash of attributes and returns the reminder id. 12 | # Please note that item_id is required as is a date as specific in the 13 | # documentation. This method can be tricky to all. 14 | def add(args) 15 | return @client.api_helper.add(args, "reminder_add") 16 | end 17 | 18 | # Update a reminder given a hash of attributes 19 | def update(args) 20 | return @client.api_helper.command(args, "reminder_update") 21 | end 22 | 23 | # Delete reminder given an array of reminders 24 | def delete(reminder) 25 | args = {id: reminder.id} 26 | return @client.api_helper.command(args, "reminder_delete") 27 | end 28 | 29 | # Clear locations which is used for location reminders 30 | def clear_locations 31 | args = {} 32 | return @client.api_helper.command(args, "clear_locations") 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/todoist/util/api_helper.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "json" 3 | require "todoist/config" 4 | require "todoist/util/network_helper" 5 | require "todoist/util/parse_helper" 6 | require "todoist/util/uuid" 7 | require "ostruct" 8 | require 'concurrent' 9 | 10 | 11 | 12 | module Todoist 13 | 14 | module Util 15 | 16 | class ApiHelper 17 | def initialize(client) 18 | @client = client 19 | @object_cache = {"projects" => Concurrent::Hash.new({}), "labels" => Concurrent::Hash.new({}), 20 | "items" => Concurrent::Hash.new({}), "notes" => Concurrent::Hash.new({}), 21 | "reminders" => Concurrent::Hash.new({}), "filters" => Concurrent::Hash.new({}) 22 | } 23 | @sync_token_cache = Concurrent::Hash.new({"projects" => "*", "labels" => "*", 24 | "items" => "*", "notes" => "*", "reminders" => "*", "filters" => "*"}) 25 | @network_helper = NetworkHelper.new(client) 26 | end 27 | 28 | def collection(type) 29 | @network_helper.sync 30 | 31 | response = @network_helper.get_sync_response({sync_token: sync_token(type), resource_types: "[\"#{type}\"]"}) 32 | response[type].each do |object_data| 33 | object = OpenStruct.new(object_data) 34 | objects(type)[object.id] = object 35 | end 36 | set_sync_token(type, response["sync_token"]) 37 | return objects(type) 38 | end 39 | 40 | def exec(args, command, temporary_resource_id) 41 | command_uuid = Uuid.command_uuid 42 | commands = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args} 43 | response = @network_helper.get_sync_response({commands: "[#{commands.to_json}]"}) 44 | raise RuntimeError, "Response returned is not ok" unless response["sync_status"][command_uuid] == "ok" 45 | return response 46 | end 47 | 48 | def command(args, command) 49 | temporary_resource_id = Uuid.temporary_resource_id 50 | command_uuid = Uuid.command_uuid 51 | command = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args} 52 | 53 | @network_helper.queue(command) 54 | return true 55 | end 56 | 57 | def add(args, command) 58 | temporary_resource_id = Uuid.temporary_resource_id 59 | command_uuid = Uuid.command_uuid 60 | command = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args} 61 | object = OpenStruct.new({temp_id: temporary_resource_id, id: temporary_resource_id}) 62 | temp_id_callback = Proc.new do |temp_id_mappings| 63 | object.id = temp_id_mappings[temporary_resource_id] if temp_id_mappings[temporary_resource_id] 64 | end 65 | 66 | @network_helper.queue(command, temp_id_callback) 67 | return object 68 | end 69 | 70 | def get_response(command, params = {}, token = true) 71 | @network_helper.get_response(command, params, token) 72 | end 73 | 74 | def get_multipart_response(command, params) 75 | @network_helper.get_multipart_response(command, params) 76 | end 77 | 78 | def multipart_file(file) 79 | @network_helper.multipart_file(file) 80 | end 81 | 82 | def sync 83 | @network_helper.sync 84 | end 85 | 86 | protected 87 | 88 | def objects(type) 89 | @object_cache[type] 90 | end 91 | 92 | def sync_token(type) 93 | @sync_token_cache[type] 94 | end 95 | 96 | def set_sync_token(type, value) 97 | @sync_token_cache[type] = value 98 | end 99 | 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/todoist/util/network_helper.rb: -------------------------------------------------------------------------------- 1 | require "net/http" 2 | require "json" 3 | require "todoist/config" 4 | require 'net/http/post/multipart' 5 | require 'mimemagic' 6 | require 'openssl' 7 | 8 | module Todoist 9 | module Util 10 | class NetworkHelper 11 | 12 | 13 | 14 | def initialize(client) 15 | @client = client 16 | @command_cache = Concurrent::Array.new([]) 17 | @command_mutex = Mutex.new 18 | @temp_id_callback_cache = Concurrent::Array.new([]) 19 | @last_request_time = 0.0 20 | 21 | end 22 | 23 | def configure_http(command) 24 | http = Net::HTTP.new(Config.getURI()[command].host, Config.getURI()[command].port) 25 | http.use_ssl = true 26 | http.verify_mode = OpenSSL::SSL::VERIFY_NONE 27 | return http 28 | end 29 | 30 | def configure_request(command, params) 31 | request = Net::HTTP::Post.new(Config.getURI()[command].request_uri) 32 | request['Authorization'] = 'Bearer ' + params[:token] 33 | request.set_form_data(params) 34 | return request 35 | end 36 | 37 | # Files need to be of class UploadIO 38 | 39 | def get_multipart_response(command, params={}) 40 | token = {token: @client.token} 41 | http = configure_http(command) 42 | url = Config.getURI()[command] 43 | http.start do 44 | req = Net::HTTP::Post::Multipart.new(url, token.merge(params)) 45 | response = http.request(req) 46 | begin 47 | return JSON.parse(response.body) 48 | rescue JSON::ParserError 49 | return response.body 50 | end 51 | end 52 | end 53 | 54 | def get_response(command, params ={}, token = true) 55 | token = token ? {token: @client.token} : {} 56 | http = configure_http(command) 57 | request = configure_request(command, token.merge(params)) 58 | retry_after_secs = Todoist::Config.retry_time 59 | # Hack to fix encoding issues with Net:HTTP for login case 60 | request.body = request.body.gsub '%40', '@' unless token 61 | while true 62 | response = throttle_request(http, request) 63 | case response.code.to_i 64 | when 200 65 | begin 66 | return JSON.parse(response.body) 67 | rescue JSON::ParserError 68 | return response.body 69 | end 70 | when 400 71 | raise StandardError, "HTTP 400 Error - The request was incorrect." 72 | when 401 73 | raise StandardError, "HTTP 401 Error - Authentication is required, and has failed, or has not yet been provided." 74 | when 403 75 | raise StandardError, "HTTP 403 Error - The request was valid, but for something that is forbidden." 76 | when 404 77 | raise StandardError, "HTTP 404 Error - The requested resource could not be found." 78 | when 429 79 | puts("Encountered 429 - retry after #{retry_after_secs}") 80 | sleep(retry_after_secs) 81 | retry_after_secs *= Todoist::Config.retry_time 82 | when 500 83 | raise StandardError, "HTTP 500 Error - The request failed due to a server error." 84 | when 503 85 | raise StandardError, "HTTP 503 Error - The server is currently unable to handle the request." 86 | end 87 | end 88 | 89 | end 90 | 91 | def throttle_request(http, request) 92 | time_since_last_request = Time.now.to_f - @last_request_time 93 | 94 | if (time_since_last_request < Todoist::Config.delay_between_requests) 95 | wait = Todoist::Config.delay_between_requests - time_since_last_request 96 | puts("Throttling request by: #{wait}") 97 | sleep(wait) 98 | end 99 | @last_request_time = Time.now.to_f 100 | http.request(request) 101 | end 102 | 103 | # Prepares a file for multipart upload 104 | def multipart_file(file) 105 | filename = File.basename(file) 106 | mime_type = MimeMagic.by_path(filename).type 107 | return UploadIO.new(file, mime_type, filename) 108 | end 109 | 110 | def queue(command, callback = nil) 111 | @command_mutex.synchronize do 112 | @command_cache.push(command) 113 | @temp_id_callback_cache.push(callback) if callback 114 | end 115 | 116 | end 117 | 118 | def sync 119 | @command_mutex.synchronize do 120 | response = get_sync_response({commands: @command_cache.to_json}) 121 | @command_cache.clear 122 | # Process callbacks here 123 | temp_id_mappings = response["temp_id_mapping"] 124 | @temp_id_callback_cache.each do |callback| 125 | callback.(temp_id_mappings) 126 | end 127 | @temp_id_callback_cache.clear 128 | end 129 | end 130 | 131 | def get_sync_response(params) 132 | get_response(Config::TODOIST_SYNC_COMMAND, params) 133 | end 134 | 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/todoist/util/parse_helper.rb: -------------------------------------------------------------------------------- 1 | require "ostruct" 2 | 3 | module Todoist 4 | module Util 5 | class ParseHelper 6 | 7 | def self.utc_offset_hours 8 | return Time.now.utc_offset/60/60 9 | end 10 | 11 | def self.parse_todoist_date(item, key) 12 | if item[key] 13 | time = Time.parse(item[key]) 14 | return time.to_datetime 15 | else 16 | return nil 17 | end 18 | end 19 | 20 | 21 | def self.filter_today(item, key) 22 | 23 | now = DateTime.now 24 | if parse_todoist_date(item, key) && parse_todoist_date(item, key) <= DateTime.new(now.year, now.month, now.day, -utc_offset_hours) + 1 25 | return true 26 | else 27 | return false 28 | end 29 | end 30 | 31 | def self.format_time(datetime) 32 | datetime.strftime("%Y-%m-%dT%H:%M") 33 | end 34 | 35 | def self.make_objects_as_array(object_datas, key = "id") 36 | objects_as_array = [] 37 | 38 | object_datas.each do |object_data| 39 | begin 40 | object = make_object(object_data) 41 | objects_as_array << object 42 | rescue 43 | # Occasionally the API returns arrays of arrays of hashes 44 | if object_data.kind_of? Array 45 | 46 | object = make_object(object_data[1]) 47 | objects_as_array << object 48 | end 49 | end 50 | end 51 | return objects_as_array 52 | end 53 | 54 | def self.make_objects_as_hash(object_datas, key = "id") 55 | objects_as_hash = {} 56 | 57 | object_datas.each do |object_data| 58 | begin 59 | object = make_object(object_data) 60 | objects_as_hash[object.send(key)] = object 61 | rescue 62 | # Occasionally the API returns arrays of arrays of hashes 63 | if object_data.kind_of? Array 64 | 65 | object = make_object(object_data[1]) 66 | objects_as_hash[object.send(key)] = object 67 | end 68 | end 69 | end 70 | return objects_as_hash 71 | end 72 | 73 | def self.make_object(object_as_hash) 74 | json = object_as_hash.to_json 75 | object = JSON.parse(json, object_class: OpenStruct) 76 | return object 77 | end 78 | 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/todoist/util/uuid.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | module Todoist 4 | module Util 5 | 6 | class Uuid 7 | 8 | def self.command_uuid 9 | return SecureRandom.uuid 10 | end 11 | 12 | def self.temporary_resource_id 13 | return SecureRandom.uuid 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/todoist/version.rb: -------------------------------------------------------------------------------- 1 | module Todoist 2 | VERSION = "0.2.7" 3 | end 4 | -------------------------------------------------------------------------------- /spec/filters_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Filters do 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "fixtures/vcr_cassettes" 8 | config.hook_into :webmock 9 | end 10 | 11 | before(:all) do 12 | Todoist::Util::Uuid.type = "filters" 13 | end 14 | 15 | before do 16 | @client = load_client 17 | end 18 | 19 | it "is able to get filters" do 20 | VCR.use_cassette("filters_is_able_to_get_filters") do 21 | filters = @client.sync_filters.collection 22 | expect(filters).to be_truthy 23 | end 24 | end 25 | 26 | it "is able to add and update filter" do 27 | VCR.use_cassette("filters_is_able_to_add_and_update_filter") do 28 | 29 | add_filter = @client.sync_filters.add({name: "FilterTest", query: "tomorrow"}) 30 | expect(add_filter).to be_truthy 31 | filters_list = @client.sync_filters.collection 32 | queried_object = filters_list[add_filter.id] 33 | expect(queried_object.name).to eq("FilterTest") 34 | queried_object.name = "FilterTestUpdate" 35 | @client.sync_filters.update({id: queried_object.id, name: queried_object.name}) 36 | filters_list = @client.sync_filters.collection 37 | queried_object = filters_list[queried_object.id] 38 | expect(queried_object.name).to eq("FilterTestUpdate") 39 | 40 | @client.sync_filters.delete(add_filter) 41 | @client.sync 42 | 43 | end 44 | end 45 | 46 | end 47 | -------------------------------------------------------------------------------- /spec/items_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Items do 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "fixtures/vcr_cassettes" 8 | config.hook_into :webmock 9 | end 10 | 11 | before(:all) do 12 | Todoist::Util::Uuid.type = "items" 13 | end 14 | 15 | before do 16 | @client = load_client 17 | end 18 | 19 | it "is able to get items" do 20 | VCR.use_cassette("items_is_able_to_get_items") do 21 | items = @client.sync_items.collection 22 | expect(items).to be_truthy 23 | end 24 | end 25 | 26 | it "is able to update a item" do 27 | VCR.use_cassette("items_is_able_to_update_a_item") do 28 | update_item = @client.sync_items.add({content: "Item3"}) 29 | expect(update_item).to be_truthy 30 | update_item.priority = 2 31 | result = @client.sync_items.update({id: update_item.id, priority: update_item.priority}) 32 | expect(result).to be_truthy 33 | items_list = @client.sync_items.collection 34 | queried_object = items_list[update_item.id] 35 | expect(queried_object.priority).to eq(2) 36 | @client.sync_items.delete(update_item.id) 37 | @client.sync 38 | end 39 | end 40 | 41 | it "is able to update multiple orders and indents" do 42 | VCR.use_cassette("items_is_able_to_update_multiple_orders_and_indents") do 43 | item = @client.sync_items.add({content: "Item1"}) 44 | expect(item).to be_truthy 45 | item2 = @client.sync_items.add({content: "Item2"}) 46 | 47 | # Restore the items fully 48 | 49 | item_collection = @client.sync_items.collection 50 | 51 | item = item_collection[item.id] 52 | item2 = item_collection[item2.id] 53 | 54 | 55 | # Keep track of original values 56 | item_order = item.item_order 57 | item_order2 = item2.item_order 58 | 59 | # Swap orders 60 | item.item_order = item_order2 61 | item2.item_order = item_order 62 | 63 | # Indent @item 64 | item.indent = 2 65 | 66 | @client.sync_items.update_multiple_orders_and_indents([item, item2]) 67 | item_collection = @client.sync_items.collection 68 | 69 | # Check to make sure newly retrieved object values match old ones 70 | 71 | expect(item_collection[item.id].item_order).to eq(item_order2) 72 | expect(item_collection[item2.id].item_order).to eq(item_order) 73 | expect(item_collection[item.id].indent).to eq(2) 74 | 75 | # Clean up extra item 76 | 77 | @client.sync_items.delete(item.id) 78 | @client.sync_items.delete(item2.id) 79 | @client.sync 80 | end 81 | 82 | end 83 | 84 | it "is able to move" do 85 | VCR.use_cassette("items_is_able_to_move") do 86 | 87 | project = @client.sync_projects.add({name: "Item_Move_Test_To"}) 88 | item = @client.sync_items.add({content: "ItemMove"}) 89 | items_list = @client.sync_items.collection 90 | queried_object = items_list[item.id] 91 | @client.sync_items.move(queried_object, project) 92 | items_list = @client.sync_items.collection 93 | queried_object = items_list[item.id] 94 | expect(queried_object.project_id).to eq(project.id) 95 | 96 | @client.sync_projects.delete(project.id) 97 | @client.sync_items.delete(item.id) 98 | @client.sync 99 | end 100 | end 101 | 102 | it "is able to complete" do 103 | VCR.use_cassette("items_is_able_to_complete") do 104 | item = @client.sync_items.add({content: "ItemComplete"}) 105 | items_list = @client.sync_items.collection 106 | 107 | queried_object = items_list[item.id] 108 | @client.sync_items.complete(queried_object) 109 | items_list = @client.sync_items.collection 110 | queried_object = items_list[item.id] 111 | expect(queried_object.checked).to eq(true) 112 | @client.sync_items.delete(queried_object.id) 113 | @client.sync 114 | end 115 | end 116 | 117 | it "is able to uncomplete" do 118 | VCR.use_cassette("items_is_able_to_uncomplete") do 119 | item = @client.sync_items.add({content: "ItemComplete"}) 120 | items_list = @client.sync_items.collection 121 | 122 | # Complete the item 123 | queried_object = items_list[item.id] 124 | @client.sync_items.complete(queried_object) 125 | items_list = @client.sync_items.collection 126 | queried_object = items_list[item.id] 127 | expect(queried_object.checked).to eq(true) 128 | 129 | # Uncomplete the item 130 | @client.sync_items.uncomplete(queried_object) 131 | items_list = @client.sync_items.collection 132 | queried_object = items_list[item.id] 133 | expect(queried_object.checked).to eq(false) 134 | 135 | @client.sync_items.delete(queried_object.id) 136 | @client.sync 137 | end 138 | end 139 | 140 | it "is able to complete a recurring task" do 141 | VCR.use_cassette("items_is_able_to_complete_a_recurring_task") do 142 | item = @client.sync_items.add({content: "ItemCompleteRecurring", due: {string: "ev 2 days"}}) 143 | items_list = @client.sync_items.collection 144 | queried_object = items_list[item.id] 145 | due_date_original = queried_object.due 146 | 147 | @client.sync_items.complete_recurring(queried_object) 148 | items_list = @client.sync_items.collection 149 | queried_object = items_list[item.id] 150 | 151 | due_date_new = queried_object.due 152 | expect(due_date_new).not_to eq(due_date_original) 153 | 154 | @client.sync_items.delete(queried_object.id) 155 | @client.sync 156 | end 157 | end 158 | 159 | it "is able to close a task" do 160 | VCR.use_cassette("items_is_able_to_close_a_task") do 161 | item = @client.sync_items.add({content: "ItemClose"}) 162 | items_list = @client.sync_items.collection 163 | 164 | queried_object = items_list[item.id] 165 | @client.sync_items.close(queried_object) 166 | items_list = @client.sync_items.collection 167 | queried_object = items_list[item.id] 168 | expect(queried_object.checked).to eq(true) 169 | @client.sync_items.delete(queried_object.id) 170 | @client.sync 171 | end 172 | end 173 | 174 | it "is able to update day orders" do 175 | VCR.use_cassette("items_is_able_to_update_day_orders") do 176 | item = @client.sync_items.add({content: "ItemDayOrder"}) 177 | items_list = @client.sync_items.collection 178 | 179 | queried_object = items_list[item.id] 180 | queried_object.day_order = 1000 181 | @client.sync_items.update_day_orders([queried_object]) 182 | items_list = @client.sync_items.collection 183 | queried_object = items_list[item.id] 184 | expect(queried_object.day_order).to eq(1000) 185 | @client.sync_items.delete(queried_object.id) 186 | @client.sync 187 | end 188 | end 189 | 190 | it "is able to delete an item" do 191 | VCR.use_cassette("items_is_able_to_delete_an_item") do 192 | item = @client.sync_items.add({content: "Item-to-be-deleted"}) 193 | expect(item).to be_truthy 194 | items_list = @client.sync_items.collection 195 | queried_object = items_list[item.id] 196 | expect(queried_object.content).to eq("Item-to-be-deleted") 197 | @client.sync_items.delete(item.id) 198 | @client.sync 199 | 200 | items_list = @client.sync_items.collection 201 | queried_object = items_list[item.id] 202 | expect(queried_object.is_deleted).to eq(true) 203 | end 204 | end 205 | 206 | 207 | end 208 | -------------------------------------------------------------------------------- /spec/labels_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Labels do 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "fixtures/vcr_cassettes" 8 | config.hook_into :webmock 9 | end 10 | 11 | before(:all) do 12 | Todoist::Util::Uuid.type = "labels" 13 | end 14 | 15 | before do 16 | @client = load_client 17 | end 18 | 19 | 20 | 21 | it "is able to get labels" do 22 | VCR.use_cassette("labels_is_able_to_get_labels") do 23 | labels = @client.sync_labels.collection 24 | expect(labels).to be_truthy 25 | end 26 | end 27 | 28 | it "is able to update a label" do 29 | VCR.use_cassette("labels_is_able_to_update_a_label") do 30 | 31 | update_label = @client.sync_labels.add({name: "Labels3"}) 32 | expect(update_label).to be_truthy 33 | update_label.color = 30 34 | result = @client.sync_labels.update({id: update_label.id, color: update_label.color}) 35 | expect(result).to be_truthy 36 | labels_list = @client.sync_labels.collection 37 | queried_object = labels_list[update_label.id] 38 | expect(queried_object.color).to eq("berry_red") 39 | @client.sync_labels.delete(update_label) 40 | @client.sync 41 | 42 | end 43 | end 44 | 45 | it "is able to update multiple orders" do 46 | VCR.use_cassette("labels_is_able_to_update_multiple_orders") do 47 | # Add the various labels 48 | label = @client.sync_labels.add({name: "Label3"}) 49 | expect(label).to be_truthy 50 | label2 = @client.sync_labels.add({name: "Label2"}) 51 | 52 | # Restore the label fully 53 | 54 | label_collection = @client.sync_labels.collection 55 | 56 | label = label_collection[label.id] 57 | label2 = label_collection[label2.id] 58 | # Keep track of original values 59 | label_order = label.item_order 60 | label_order2 = label2.item_order 61 | 62 | # Swap orders 63 | label.item_order = label_order2 64 | label2.item_order = label_order 65 | 66 | 67 | @client.sync_labels.update_multiple_orders([label, label2]) 68 | label_collection = @client.sync_labels.collection 69 | 70 | # Check to make sure newly retrieved object values match old ones 71 | 72 | expect(label_collection[label.id].item_order).to eq(label_order2) 73 | expect(label_collection[label2.id].item_order).to eq(label_order) 74 | 75 | # Clean up extra label 76 | 77 | @client.sync_labels.delete(label2) 78 | @client.sync_labels.delete(label) 79 | @client.sync 80 | end 81 | end 82 | 83 | end 84 | -------------------------------------------------------------------------------- /spec/misc_activity_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Activity do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_activity" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to get activity" do 21 | VCR.use_cassette("misc_activity_is_able_to_get_activity") do 22 | result = @client.misc_activity.get 23 | expect(result).to be_truthy 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/misc_backups_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Backups do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_backups" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to get backups" do 21 | VCR.use_cassette("misc_backups_is_able_to_get_backups") do 22 | result = @client.misc_backups.get 23 | expect(result).to be_truthy 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/misc_completed_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Completed do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_completed" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to get productivity stats" do 21 | VCR.use_cassette("misc_completed_is_able_to_get_productivity_stats") do 22 | result = @client.misc_completed.get_productivity_stats 23 | expect(result).to be_truthy 24 | end 25 | end 26 | 27 | it "is able to get all completed items" do 28 | VCR.use_cassette("misc_completed_is_able_to_get_all_completed_items") do 29 | since = Date.today - 7 30 | result = @client.misc_completed.get_all_completed_items({"since" => since}) 31 | expect(result["items"]).to be_truthy 32 | end 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /spec/misc_items_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Items do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_items" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to add and get an item" do 21 | VCR.use_cassette("misc_items_is_able_to_add_and_get_an_item") do 22 | item = @client.misc_items.add_item("Test quick add content") 23 | expect(item).to be_truthy 24 | 25 | item_data = @client.misc_items.get_item(item) 26 | @client.sync_items.delete([item]) 27 | @client.sync 28 | end 29 | end 30 | 31 | it "is able to add a priority item" do 32 | VCR.use_cassette("misc_items_is_able_to_add_a_priority_item") do 33 | item = @client.misc_items.add_item("Test quick add content", priority: 4) 34 | expect(item).to be_truthy 35 | expect(item.priority).to eq(4) 36 | 37 | item_data = @client.misc_items.get_item(item) 38 | @client.sync_items.delete([item]) 39 | @client.sync 40 | end 41 | end 42 | 43 | 44 | end 45 | -------------------------------------------------------------------------------- /spec/misc_projects_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Projects do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_projects" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to get archived projects" do 21 | VCR.use_cassette("misc_projects_is_able_to_get_archived_projects") do 22 | result = @client.misc_projects.get_archived_projects 23 | expect(result).to be_truthy 24 | end 25 | end 26 | 27 | it "is able to get project info and project data" do 28 | VCR.use_cassette("misc_projects_is_able_to_get_project_info_and_project_data") do 29 | # Create an item and a project 30 | item = @client.sync_items.add({content: "Item3"}) 31 | project = @client.sync_projects.add({name: "Project1"}) 32 | @client.sync 33 | 34 | # Add a note to the project 35 | note = @client.sync_notes.add({content: "NoteForMiscProjectTest", project_id: project.id}) 36 | 37 | # Move requires fully inflated object 38 | items_list = @client.sync_items.collection 39 | item = items_list[item.id] 40 | 41 | # Move the item to the project 42 | @client.sync_items.move(item, project) 43 | 44 | 45 | @client.sync 46 | # Get project info 47 | result = @client.misc_projects.get_project_info(project) 48 | 49 | expect(result["project"]).to be_truthy 50 | expect(result["notes"]).to be_truthy 51 | 52 | # Get project data 53 | result = @client.misc_projects.get_project_data(project) 54 | expect(result["project"]).to be_truthy 55 | expect(result["items"]).to be_truthy 56 | 57 | # Clean up 58 | 59 | @client.sync_items.delete([item]) 60 | @client.sync_projects.delete([project]) 61 | @client.sync_notes.delete(note) 62 | @client.sync 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /spec/misc_quick.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Items do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "misc_quick" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to quick add an item" do 21 | VCR.use_cassette("misc_quick_is_able_to_quick_add_an_item") do 22 | item = @client.misc_quick.add_item("Test quick add content today") 23 | expect(item).to be_truthy 24 | expect(item.due).to be_truthy 25 | @client.sync_items.delete([item]) 26 | @client.sync 27 | end 28 | end 29 | 30 | end 31 | -------------------------------------------------------------------------------- /spec/misc_templates_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Templates do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "templates" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to import into a project" do 21 | VCR.use_cassette("misc_templates_is_able_to_import_into_a_project") do 22 | project = @client.sync_projects.add({name: "TemplatesImport"}) 23 | @client.sync_projects.collection 24 | file = File.open("spec/template_sample.csv","r") 25 | result = @client.misc_templates.import_into_project(project, file) 26 | expect(result).to be_truthy 27 | @client.sync_projects.delete([project]) 28 | @client.sync 29 | end 30 | end 31 | 32 | it "is able to export as a file" do 33 | VCR.use_cassette("misc_templates_is_able_to_export_as_a_file") do 34 | project = @client.sync_projects.add({name: "TemplatesExport"}) 35 | @client.sync_projects.collection 36 | file = @client.misc_templates.export_as_file(project) 37 | expect(file).to include("TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE") 38 | @client.sync_projects.delete([project]) 39 | @client.sync 40 | end 41 | end 42 | 43 | it "is able to export as a url" do 44 | VCR.use_cassette("misc_templates_is_able_to_export_as_a_url") do 45 | project = @client.sync_projects.add({name: "TemplatesExport"}) 46 | @client.sync_projects.collection 47 | result = @client.misc_templates.export_as_url(project) 48 | expect(result["file_name"]).to be_truthy 49 | @client.sync_projects.delete([project]) 50 | @client.sync 51 | end 52 | end 53 | 54 | 55 | end 56 | -------------------------------------------------------------------------------- /spec/misc_uploads_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Misc::Uploads do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "uploads" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | end 19 | 20 | it "is able to upload a file, find it, then delete it" do 21 | VCR.use_cassette("misc_uploads_is_able_to_upload_a_file_find_it_then_delete_it") do 22 | file = File.open("spec/template_sample.csv","r") 23 | added_file = @client.misc_uploads.add(file) 24 | expect(added_file["file_name"]).to be_truthy 25 | result = @client.misc_uploads.get() 26 | expect(result).to be_truthy 27 | @client.misc_uploads.delete(added_file["file_url"]) 28 | end 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /spec/notes_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Notes do 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "fixtures/vcr_cassettes" 8 | config.hook_into :webmock 9 | end 10 | 11 | before(:all) do 12 | Todoist::Util::Uuid.type = "notes" 13 | end 14 | 15 | before do 16 | @client = load_client 17 | end 18 | 19 | it "is able to get notes" do 20 | VCR.use_cassette("notes_is_able_to_get_notes") do 21 | notes = @client.sync_notes.collection 22 | expect(notes).to be_truthy 23 | end 24 | end 25 | 26 | it "is able to add and update note" do 27 | VCR.use_cassette("notes_is_able_to_add_and_update_note") do 28 | note_item = @client.sync_items.add({content: "ItemForNoteTest"}) 29 | add_note = @client.sync_notes.add({content: "NoteForItemNoteTest", item_id: note_item.id}) 30 | expect(add_note).to be_truthy 31 | notes_list = @client.sync_notes.collection 32 | queried_object = notes_list[add_note.id] 33 | expect(queried_object.item_id).to eq(note_item.id) 34 | expect(queried_object.content).to eq("NoteForItemNoteTest") 35 | queried_object.content = "NoteForItemNoteTestUpdate" 36 | @client.sync_notes.update({id: queried_object.id, content: "NoteForItemNoteTestUpdate"}) 37 | notes_list = @client.sync_notes.collection 38 | queried_object = notes_list[queried_object.id] 39 | expect(queried_object.content).to eq("NoteForItemNoteTestUpdate") 40 | 41 | @client.sync_items.delete([note_item]) 42 | @client.sync_notes.delete(add_note) 43 | @client.sync 44 | 45 | end 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /spec/projects_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Projects do 6 | 7 | VCR.configure do |config| 8 | config.cassette_library_dir = "fixtures/vcr_cassettes" 9 | config.hook_into :webmock 10 | end 11 | 12 | before(:all) do 13 | Todoist::Util::Uuid.type = "projects" 14 | end 15 | 16 | before do 17 | @client = load_client 18 | 19 | end 20 | 21 | it "is able to get projects" do 22 | VCR.use_cassette("projects_is_able_to_get_projects") do 23 | projects = @client.sync_projects.collection 24 | expect(projects).to be_truthy 25 | end 26 | end 27 | 28 | it "is able to archive and unarchive projects" do 29 | VCR.use_cassette("projects_is_able_to_archive_and_unarchive_projects") do 30 | project = @client.sync_projects.add({name: "Project1"}) 31 | expect(project).to be_truthy 32 | result = @client.sync_projects.archive([project]) 33 | expect(result).to be_truthy 34 | result = @client.sync_projects.unarchive([project]) 35 | expect(result).to be_truthy 36 | @client.sync_projects.delete(project.id) 37 | @client.sync 38 | end 39 | end 40 | 41 | it "is able to update a project" do 42 | VCR.use_cassette("projects_is_able_to_update_a_project") do 43 | update_project = @client.sync_projects.add({name: "Project4"}) 44 | update_project.name = "Project4Update" 45 | result = @client.sync_projects.update(id: update_project.id, name: update_project.name) 46 | expect(result).to be_truthy 47 | queried_object = @client.sync_projects.collection[update_project.id] 48 | expect(queried_object.name).to eq(update_project.name) 49 | @client.sync_projects.delete(update_project.id) 50 | @client.sync 51 | end 52 | end 53 | 54 | it "is able to update multiple orders and indents" do 55 | VCR.use_cassette("projects_is_able_to_update_multiple_orders_and_indents") do 56 | project = @client.sync_projects.add({name: "Project1"}) 57 | expect(project).to be_truthy 58 | project2 = @client.sync_projects.add({name: "Project2"}) 59 | 60 | # Restore the projects fully 61 | 62 | project_collection = @client.sync_projects.collection 63 | 64 | project = project_collection[project.id] 65 | project2 = project_collection[project2.id] 66 | 67 | 68 | # Keep track of original values 69 | project_order = project.item_order 70 | project_order2 = project2.item_order 71 | 72 | # Swap orders 73 | project.item_order = project_order2 74 | project2.item_order = project_order 75 | 76 | # Indent project 77 | project.indent = 2 78 | 79 | @client.sync_projects.update_multiple_orders_and_indents([project, project2]) 80 | project_collection = @client.sync_projects.collection 81 | 82 | # Check to make sure newly retrieved object values match old ones 83 | 84 | expect(project_collection[project.id].item_order).to eq(project_order2) 85 | expect(project_collection[project2.id].item_order).to eq(project_order) 86 | expect(project_collection[project.id].indent).to eq(2) 87 | 88 | # Clean up extra project 89 | 90 | @client.sync_projects.delete(project2.id) 91 | @client.sync_projects.delete(project.id) 92 | @client.sync 93 | end 94 | 95 | 96 | end 97 | 98 | it "is able to delete a project" do 99 | VCR.use_cassette("projects_is_able_to_delete_a_project") do 100 | project = @client.sync_projects.add({name: "Project-to-be-deleted"}) 101 | expect(project).to be_truthy 102 | projects_list = @client.sync_projects.collection 103 | queried_object = projects_list[project.id] 104 | expect(queried_object.name).to eq("Project-to-be-deleted") 105 | @client.sync_projects.delete(project.id) 106 | @client.sync 107 | 108 | projects_list = @client.sync_projects.collection 109 | queried_object = projects_list[project.id] 110 | expect(queried_object.is_deleted).to eq(true) 111 | end 112 | end 113 | 114 | end 115 | -------------------------------------------------------------------------------- /spec/reminders_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require 'pry' 3 | require 'vcr' 4 | 5 | describe Todoist::Sync::Reminders do 6 | VCR.configure do |config| 7 | config.cassette_library_dir = "fixtures/vcr_cassettes" 8 | config.hook_into :webmock 9 | end 10 | 11 | before(:all) do 12 | Todoist::Util::Uuid.type = "reminders" 13 | end 14 | 15 | before do 16 | @client = load_client 17 | end 18 | 19 | it "is able to get reminders" do 20 | VCR.use_cassette("reminders_is_able_to_get_reminders") do 21 | reminders = @client.sync_reminders.collection 22 | expect(reminders).to be_truthy 23 | end 24 | end 25 | 26 | it "is able to add and update reminder" do 27 | VCR.use_cassette("reminders_is_able_to_add_and_update_reminder") do 28 | reminder_item = @client.sync_items.add({content: "ItemForReminderTest"}) 29 | @client.sync_items.collection 30 | add_reminder = @client.sync_reminders.add({item_id: reminder_item.id, 31 | type: "absolute", due: {string: "tomorrow"}}) 32 | expect(add_reminder).to be_truthy 33 | reminders_list = @client.sync_reminders.collection 34 | queried_object = reminders_list[add_reminder.id] 35 | expect(queried_object.item_id).to eq(reminder_item.id) 36 | queried_object.due["string"] = "next week" 37 | @client.sync_reminders.update({id: queried_object.id, service: queried_object.service}) 38 | reminders_list = @client.sync_reminders.collection 39 | queried_object = reminders_list[queried_object.id] 40 | expect(queried_object.due["string"]).to eq("next week") 41 | 42 | @client.sync_items.delete([reminder_item]) 43 | @client.sync_reminders.delete(add_reminder) 44 | @client.sync 45 | 46 | end 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 2 | require 'simplecov' 3 | SimpleCov.start 4 | 5 | require "todoist" 6 | require 'yaml' 7 | require 'securerandom' 8 | 9 | def load_client 10 | begin 11 | token = File.open("spec/token", "r").read 12 | rescue 13 | puts "Please create a file called token in the spec folder and have the first line be your Todoist token for testing purposes" 14 | exit 15 | end 16 | Todoist::Client.create_client_by_token(token) 17 | 18 | end 19 | 20 | module Todoist 21 | module Util 22 | 23 | 24 | 25 | class Uuid 26 | 27 | @@type = "" 28 | @@uuids = {"command_uuid" => [], "temporary_resource_id" => []} 29 | 30 | def self.command_uuid 31 | return pop_uuid("command_uuid") 32 | end 33 | 34 | def self.temporary_resource_id 35 | return pop_uuid("temporary_resource_id") 36 | end 37 | 38 | def self.pop_uuid(resource) 39 | path = "fixtures/uuid/#{@@type}_#{resource}.yml" 40 | # File does not exist 41 | if !File.exist?(path) 42 | 100.times do 43 | @@uuids[resource] << SecureRandom.uuid 44 | end 45 | File.write(path, @@uuids[resource].to_yaml) 46 | elsif File.exist?(path) && @@uuids[resource].empty? 47 | @@uuids[resource] = YAML.load_file(path) 48 | end 49 | 50 | return @@uuids[resource].pop 51 | end 52 | 53 | def self.type= (type) 54 | reset_uuids 55 | @@type = type 56 | end 57 | 58 | def self.type 59 | @@type 60 | end 61 | 62 | def self.reset_uuids 63 | @@uuids = {"command_uuid" => [], "temporary_resource_id" => []} 64 | end 65 | end 66 | end 67 | end 68 | 69 | -------------------------------------------------------------------------------- /spec/template_sample.csv: -------------------------------------------------------------------------------- 1 | TYPE,CONTENT,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE 2 | task,test import,2,1,,,ev 3 days,en,America/Los_Angeles 3 | -------------------------------------------------------------------------------- /todoist.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'todoist/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "todoist-ruby" 8 | spec.version = Todoist::VERSION 9 | spec.authors = ["Han Yuan"] 10 | spec.email = ["han.yuan@gmail.com"] 11 | 12 | spec.summary = %q{This gem provides access to the latest Todoist API.} 13 | spec.description = %q{This gem provides access to the latest Todoist API. It is designed to be as lightweight as possible. While the gem provides interfaces for the sync API calls, no support is provided for persisting the result of these calls. Collaboration API calls are not supported at this time.} 14 | spec.homepage = "https://github.com/h6y3/todoist-ruby" 15 | spec.license = "MIT" 16 | 17 | # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host' 18 | # to allow pushing to a single host or delete this section to allow pushing to any host. 19 | if spec.respond_to?(:metadata) 20 | spec.metadata['allowed_push_host'] = "https://rubygems.org" 21 | else 22 | raise "RubyGems 2.0 or newer is required to protect against " \ 23 | "public gem pushes." 24 | end 25 | 26 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 27 | f.match(%r{^(test|spec|features)/}) 28 | end 29 | spec.bindir = "exe" 30 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 31 | spec.require_paths = ["lib"] 32 | 33 | spec.add_development_dependency "bundler", ">= 2.0.1" 34 | spec.add_development_dependency "rake", ">= 12.3.3" 35 | spec.add_development_dependency "rspec", ">=3.0" 36 | spec.add_development_dependency "pry", ">= 0.10" 37 | spec.add_development_dependency "vcr", ">= 3.0" 38 | spec.add_development_dependency "webmock", ">= 3.0" 39 | spec.add_development_dependency "simplecov", ">= 0.10" 40 | spec.add_dependency "concurrent-ruby", ">= 1.0" 41 | spec.add_dependency "multipart-post", ">= 2.0" 42 | spec.add_dependency "mimemagic", ">= 0.3" 43 | spec.add_dependency "faraday", ">= 1.0" 44 | 45 | end 46 | --------------------------------------------------------------------------------