todoist-ruby 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.travis.yml +5 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +201 -0
- data/Rakefile +20 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/fixtures/.gitignore +6 -0
- data/fixtures/uuid/.gitignore +4 -0
- data/fixtures/vcr_cassettes/.gitignore +4 -0
- data/lib/todoist.rb +23 -0
- data/lib/todoist/misc/activity.rb +51 -0
- data/lib/todoist/misc/backups.rb +13 -0
- data/lib/todoist/misc/completed.rb +34 -0
- data/lib/todoist/misc/items.rb +55 -0
- data/lib/todoist/misc/projects.rb +31 -0
- data/lib/todoist/misc/query.rb +30 -0
- data/lib/todoist/misc/quick.rb +15 -0
- data/lib/todoist/misc/templates.rb +29 -0
- data/lib/todoist/misc/uploads.rb +28 -0
- data/lib/todoist/misc/user.rb +16 -0
- data/lib/todoist/sync/filters.rb +44 -0
- data/lib/todoist/sync/items.rb +107 -0
- data/lib/todoist/sync/labels.rb +39 -0
- data/lib/todoist/sync/notes.rb +32 -0
- data/lib/todoist/sync/projects.rb +56 -0
- data/lib/todoist/sync/reminders.rb +36 -0
- data/lib/todoist/util/api_helper.rb +89 -0
- data/lib/todoist/util/command_synchronizer.rb +54 -0
- data/lib/todoist/util/config.rb +78 -0
- data/lib/todoist/util/network_helper.rb +100 -0
- data/lib/todoist/util/parse_helper.rb +62 -0
- data/lib/todoist/util/uuid.rb +17 -0
- data/lib/todoist/version.rb +3 -0
- data/todoist.gemspec +44 -0
- metadata +227 -0
@@ -0,0 +1,32 @@
|
|
1
|
+
module Todoist
|
2
|
+
module Sync
|
3
|
+
class Notes
|
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 ApiHelper.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 ApiHelper.add(args, "note_add")
|
16
|
+
end
|
17
|
+
|
18
|
+
# Update a note given a note
|
19
|
+
def update(note)
|
20
|
+
return ApiHelper.command(note.to_h, "note_update")
|
21
|
+
end
|
22
|
+
|
23
|
+
# Delete notes given an a note
|
24
|
+
def delete(note)
|
25
|
+
args = {id: note.id}
|
26
|
+
return ApiHelper.command(args, "note_delete")
|
27
|
+
end
|
28
|
+
|
29
|
+
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Todoist
|
2
|
+
module Sync
|
3
|
+
|
4
|
+
|
5
|
+
class Projects
|
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 ApiHelper.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 ApiHelper.add(args, "project_add")
|
16
|
+
end
|
17
|
+
|
18
|
+
# Delete projects given an array of projects
|
19
|
+
def delete(projects)
|
20
|
+
project_ids = projects.collect { |project| project.id }
|
21
|
+
args = {ids: project_ids.to_json}
|
22
|
+
return ApiHelper.command(args, "project_delete")
|
23
|
+
end
|
24
|
+
|
25
|
+
# Archive projects given an array of projects
|
26
|
+
def archive(projects)
|
27
|
+
project_ids = projects.collect { |project| project.id }
|
28
|
+
args = {ids: project_ids.to_json}
|
29
|
+
return ApiHelper.command(args, "project_archive")
|
30
|
+
end
|
31
|
+
|
32
|
+
# Unarchive projects given an array of projects
|
33
|
+
def unarchive(projects)
|
34
|
+
project_ids = projects.collect { |project| project.id }
|
35
|
+
args = {ids: project_ids.to_json}
|
36
|
+
return ApiHelper.command(args, "project_unarchive")
|
37
|
+
end
|
38
|
+
|
39
|
+
# Update project given a project
|
40
|
+
def update(project)
|
41
|
+
return ApiHelper.command(project.to_h, "project_update")
|
42
|
+
end
|
43
|
+
|
44
|
+
# Update orders and indents for an array of projects
|
45
|
+
def update_multiple_orders_and_indents(projects)
|
46
|
+
tuples = {}
|
47
|
+
projects.each do |project|
|
48
|
+
tuples[project.id] = [project.item_order, project.indent]
|
49
|
+
end
|
50
|
+
args = {ids_to_orders_indents: tuples.to_json}
|
51
|
+
return ApiHelper.command(args, "project_update_orders_indents")
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
module Todoist
|
2
|
+
module Sync
|
3
|
+
class Reminders
|
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 ApiHelper.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 ApiHelper.add(args, "reminder_add")
|
16
|
+
end
|
17
|
+
|
18
|
+
# Update a reminder given a reminder
|
19
|
+
def update(reminder)
|
20
|
+
return ApiHelper.command(reminder.to_h, "reminder_update")
|
21
|
+
end
|
22
|
+
|
23
|
+
# Delete reminder given an array of reminders
|
24
|
+
def delete(reminder)
|
25
|
+
args = {id: reminder.id}
|
26
|
+
return ApiHelper.command(args, "reminder_delete")
|
27
|
+
end
|
28
|
+
|
29
|
+
# Clear locations which is used for location reminders
|
30
|
+
def clear_locations
|
31
|
+
args = {}
|
32
|
+
return ApiHelper.command(args, "clear_locations")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,89 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "json"
|
3
|
+
require "todoist/util/config"
|
4
|
+
require "todoist/util/network_helper"
|
5
|
+
require "todoist/util/parse_helper"
|
6
|
+
require "todoist/util/uuid"
|
7
|
+
require "todoist/util/command_synchronizer"
|
8
|
+
require "ostruct"
|
9
|
+
require 'concurrent'
|
10
|
+
|
11
|
+
|
12
|
+
|
13
|
+
module Todoist
|
14
|
+
|
15
|
+
module Util
|
16
|
+
|
17
|
+
class ApiHelper
|
18
|
+
|
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
|
+
|
26
|
+
def self.collection(type)
|
27
|
+
CommandSynchronizer.sync
|
28
|
+
|
29
|
+
response = getSyncResponse({sync_token: sync_token(type), resource_types: "[\"#{type}\"]"})
|
30
|
+
response[type].each do |object_data|
|
31
|
+
object = OpenStruct.new(object_data)
|
32
|
+
objects(type)[object.id] = object
|
33
|
+
end
|
34
|
+
set_sync_token(type, response["sync_token"])
|
35
|
+
return objects(type)
|
36
|
+
end
|
37
|
+
|
38
|
+
def self.exec(args, command, temporary_resource_id)
|
39
|
+
command_uuid = Uuid.command_uuid
|
40
|
+
commands = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args}
|
41
|
+
response = getSyncResponse({commands: "[#{commands.to_json}]"})
|
42
|
+
raise RuntimeError, "Response returned is not ok" unless response["sync_status"][command_uuid] == "ok"
|
43
|
+
return response
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.command(args, command)
|
47
|
+
temporary_resource_id = Uuid.temporary_resource_id
|
48
|
+
command_uuid = Uuid.command_uuid
|
49
|
+
command = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args}
|
50
|
+
CommandSynchronizer.queue(command)
|
51
|
+
return true
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.add(args, command)
|
55
|
+
temporary_resource_id = Uuid.temporary_resource_id
|
56
|
+
command_uuid = Uuid.command_uuid
|
57
|
+
command = {type: command, temp_id: temporary_resource_id, uuid: command_uuid, args: args}
|
58
|
+
object = OpenStruct.new({temp_id: temporary_resource_id, id: temporary_resource_id})
|
59
|
+
temp_id_callback = Proc.new do |temp_id_mappings|
|
60
|
+
object.id = temp_id_mappings[temporary_resource_id] if temp_id_mappings[temporary_resource_id]
|
61
|
+
end
|
62
|
+
|
63
|
+
CommandSynchronizer.queue(command, temp_id_callback)
|
64
|
+
return object
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.getSyncResponse(params)
|
68
|
+
NetworkHelper.getResponse(Config::TODOIST_SYNC_COMMAND, params)
|
69
|
+
end
|
70
|
+
|
71
|
+
protected
|
72
|
+
|
73
|
+
|
74
|
+
|
75
|
+
def self.objects(type)
|
76
|
+
@@object_cache[type]
|
77
|
+
end
|
78
|
+
|
79
|
+
def self.sync_token(type)
|
80
|
+
@@sync_token_cache[type]
|
81
|
+
end
|
82
|
+
|
83
|
+
def self.set_sync_token(type, value)
|
84
|
+
@@sync_token_cache[type] = value
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'concurrent'
|
2
|
+
require "todoist/util/api_helper"
|
3
|
+
|
4
|
+
module Todoist
|
5
|
+
|
6
|
+
module Util
|
7
|
+
class CommandSynchronizer
|
8
|
+
|
9
|
+
@@command_cache = Concurrent::Array.new([])
|
10
|
+
@@command_mutex = Mutex.new
|
11
|
+
@@temp_id_callback_cache = Concurrent::Array.new([])
|
12
|
+
|
13
|
+
def self.start
|
14
|
+
@@sync_thread = Thread.new do
|
15
|
+
while(true) do
|
16
|
+
process()
|
17
|
+
sleep(3)
|
18
|
+
end
|
19
|
+
end unless @@sync_thread
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.stop
|
23
|
+
Thread.kill(@@sync_thread) if @@sync_thread
|
24
|
+
@@sync_thread = nil
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.queue(command, callback = nil)
|
28
|
+
@@command_mutex.synchronize do
|
29
|
+
@@command_cache.push(command)
|
30
|
+
@@temp_id_callback_cache.push(callback) if callback
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.sync
|
35
|
+
@@command_mutex.synchronize do
|
36
|
+
response = ApiHelper.getSyncResponse({commands: @@command_cache.to_json})
|
37
|
+
@@command_cache.clear
|
38
|
+
# Process callbacks here
|
39
|
+
temp_id_mappings = response["temp_id_mapping"]
|
40
|
+
@@temp_id_callback_cache.each do |callback|
|
41
|
+
callback.(temp_id_mappings)
|
42
|
+
end
|
43
|
+
@@temp_id_callback_cache.clear
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
protected
|
49
|
+
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
module Todoist
|
2
|
+
module Util
|
3
|
+
class Config
|
4
|
+
TODOIST_API_URL = "https://todoist.com/API/v7"
|
5
|
+
|
6
|
+
# List of commands supported
|
7
|
+
@@command_list = [
|
8
|
+
TODOIST_SYNC_COMMAND = "/sync",
|
9
|
+
TODOIST_QUERY_COMMAND = "/query",
|
10
|
+
TODOIST_TEMPLATES_IMPORT_INTO_PROJECT_COMMAND = "/templates/import_into_project",
|
11
|
+
TODOIST_TEMPLATES_EXPORT_AS_FILE_COMMAND = "/templates/export_as_file",
|
12
|
+
TODOIST_TEMPLATES_EXPORT_AS_URL_COMMAND = "/templates/export_as_url",
|
13
|
+
TODOIST_UPLOADS_ADD_COMMAND = "/uploads/add",
|
14
|
+
TODOIST_UPLOADS_GET_COMMAND = "/uploads/get",
|
15
|
+
TODOIST_UPLOADS_DELETE_COMMAND = "/uploads/delete",
|
16
|
+
TODOIST_COMPLETED_GET_STATS_COMMAND = "/completed/get_stats",
|
17
|
+
TODOIST_COMPLETED_GET_ALL_COMMAND = "/completed/get_all",
|
18
|
+
TODOIST_PROJECTS_GET_ARCHIVED_COMMAND = "/projects/get_archived",
|
19
|
+
TODOIST_PROJECTS_GET_COMMAND = "/projects/get",
|
20
|
+
TODOIST_PROJECTS_GET_DATA_COMMAND = "/projects/get_data",
|
21
|
+
TODOIST_ITEMS_ADD_COMMAND = "/items/add",
|
22
|
+
TODOIST_ITEMS_GET_COMMAND = "/items/get",
|
23
|
+
TODOIST_QUICK_ADD_COMMAND = "/quick/add",
|
24
|
+
TODOIST_ACTIVITY_GET_COMMAND = "/activity/get",
|
25
|
+
TODOIST_BACKUPS_GET_COMMAND = "/backups/get",
|
26
|
+
TODOIST_USER_LOGIN_COMMAND = "/user/login"
|
27
|
+
]
|
28
|
+
|
29
|
+
# Map of commands to URIs
|
30
|
+
@@uri = nil
|
31
|
+
|
32
|
+
# User token
|
33
|
+
@@token = nil
|
34
|
+
|
35
|
+
# Artificial delay between requests to avoid API throttling
|
36
|
+
@@delay_between_requests = 0
|
37
|
+
|
38
|
+
# Should API throttling happen (HTTP Error 429), retry_time between requests
|
39
|
+
# with exponential backoff
|
40
|
+
@@retry_time = 20
|
41
|
+
|
42
|
+
def self.token=(token)
|
43
|
+
@@token = token
|
44
|
+
end
|
45
|
+
|
46
|
+
def self.token
|
47
|
+
@@token
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.retry_time=(retry_time)
|
51
|
+
@@retry_time = retry_time
|
52
|
+
end
|
53
|
+
|
54
|
+
def self.retry_time
|
55
|
+
@@retry_time
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.delay_between_requests=(delay_between_requests)
|
59
|
+
@@delay_between_requests = delay_between_requests
|
60
|
+
end
|
61
|
+
|
62
|
+
def self.delay_between_requests
|
63
|
+
@@delay_between_requests
|
64
|
+
end
|
65
|
+
|
66
|
+
def self.getURI
|
67
|
+
if @@uri == nil
|
68
|
+
@@uri = {}
|
69
|
+
@@command_list.each do |command|
|
70
|
+
@@uri[command] = URI.parse(TODOIST_API_URL + command)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
return @@uri
|
74
|
+
end
|
75
|
+
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "net/http"
|
2
|
+
require "json"
|
3
|
+
require "todoist/util/config"
|
4
|
+
require 'net/http/post/multipart'
|
5
|
+
require 'mimemagic'
|
6
|
+
|
7
|
+
module Todoist
|
8
|
+
module Util
|
9
|
+
class NetworkHelper
|
10
|
+
|
11
|
+
@@last_request_time = 0.0
|
12
|
+
|
13
|
+
def self.configureHTTP(command)
|
14
|
+
http = Net::HTTP.new(Config.getURI()[command].host, Config.getURI()[command].port)
|
15
|
+
http.use_ssl = true
|
16
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
17
|
+
return http
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.configureRequest(command, params)
|
21
|
+
request = Net::HTTP::Post.new(Config.getURI()[command].request_uri)
|
22
|
+
request.set_form_data(params)
|
23
|
+
return request
|
24
|
+
end
|
25
|
+
|
26
|
+
# Files need to be of class UploadIO
|
27
|
+
|
28
|
+
def self.getMultipartResponse(command, params={})
|
29
|
+
token = {token: Todoist::Util::Config.token}
|
30
|
+
http = configureHTTP(command)
|
31
|
+
url = Config.getURI()[command]
|
32
|
+
http.start do
|
33
|
+
req = Net::HTTP::Post::Multipart.new(url, token.merge(params))
|
34
|
+
response = http.request(req)
|
35
|
+
begin
|
36
|
+
return JSON.parse(response.body)
|
37
|
+
rescue JSON::ParserError
|
38
|
+
return response.body
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.getResponse(command, params ={}, token = true)
|
44
|
+
token = token ? {token: Todoist::Util::Config.token} : {}
|
45
|
+
http = configureHTTP(command)
|
46
|
+
request = configureRequest(command, token.merge(params))
|
47
|
+
retry_after_secs = Todoist::Util::Config.retry_time
|
48
|
+
# Hack to fix encoding issues with Net:HTTP for login case
|
49
|
+
request.body = request.body.gsub '%40', '@' unless token
|
50
|
+
while true
|
51
|
+
response = throttle_request(http, request)
|
52
|
+
case response.code.to_i
|
53
|
+
when 200
|
54
|
+
begin
|
55
|
+
return JSON.parse(response.body)
|
56
|
+
rescue JSON::ParserError
|
57
|
+
return response.body
|
58
|
+
end
|
59
|
+
when 400
|
60
|
+
raise StandardError, "HTTP 400 Error - The request was incorrect."
|
61
|
+
when 401
|
62
|
+
raise StandardError, "HTTP 401 Error - Authentication is required, and has failed, or has not yet been provided."
|
63
|
+
when 403
|
64
|
+
raise StandardError, "HTTP 403 Error - The request was valid, but for something that is forbidden."
|
65
|
+
when 404
|
66
|
+
raise StandardError, "HTTP 404 Error - The requested resource could not be found."
|
67
|
+
when 429
|
68
|
+
puts("Encountered 429 - retry after #{retry_after_secs}")
|
69
|
+
sleep(retry_after_secs)
|
70
|
+
retry_after_secs *= Todoist::Util::Config.retry_time
|
71
|
+
when 500
|
72
|
+
raise StandardError, "HTTP 500 Error - The request failed due to a server error."
|
73
|
+
when 503
|
74
|
+
raise StandardError, "HTTP 503 Error - The server is currently unable to handle the request."
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
79
|
+
|
80
|
+
def self.throttle_request(http, request)
|
81
|
+
time_since_last_request = Time.now.to_f - @@last_request_time
|
82
|
+
|
83
|
+
if (time_since_last_request < Todoist::Util::Config.delay_between_requests)
|
84
|
+
wait = Todoist::Util::Config.delay_between_requests - time_since_last_request
|
85
|
+
puts("Throttling request by: #{wait}")
|
86
|
+
sleep(wait)
|
87
|
+
end
|
88
|
+
@@last_request_time = Time.now.to_f
|
89
|
+
http.request(request)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Prepares a file for multipart upload
|
93
|
+
def self.multipart_file(file)
|
94
|
+
filename = File.basename(file)
|
95
|
+
mime_type = MimeMagic.by_path(filename).type
|
96
|
+
return UploadIO.new(file, mime_type, filename)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|