todoist_rest_client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/todoist/comment.rb +37 -0
- data/lib/todoist/current_time.rb +13 -0
- data/lib/todoist/date_parser.rb +26 -0
- data/lib/todoist/due.rb +83 -0
- data/lib/todoist/file_attachment.rb +5 -0
- data/lib/todoist/label.rb +29 -0
- data/lib/todoist/project.rb +103 -0
- data/lib/todoist/section.rb +16 -0
- data/lib/todoist/struct_cleanup.rb +42 -0
- data/lib/todoist/sync.rb +51 -0
- data/lib/todoist/task.rb +79 -0
- data/lib/todoist/utilities.rb +63 -0
- data/lib/todoist_client/version.rb +3 -0
- data/lib/todoist_client.rb +487 -0
- metadata +155 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 599001423d712e315184e5ce3b5bad778d95c9b4ea7cbd16e867f040eaadcc1a
|
|
4
|
+
data.tar.gz: eb3fbdbd38a3b8d7a080aaf36119375f22c10db8158147e250c59d96ed965ffd
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 4f136159a0b8814302c469e0fe0d99e075d83a78a9749ea1abf3395ffc2f48877a34ff81837af83c2c38cb8c95459a37a666c8d48960570f85f2e9ce7fbe0361
|
|
7
|
+
data.tar.gz: c6890c8bf08ea4852141a437da075885abe1706c75afa9da522f25cc871fc4238c6d22411a795851d74a8efbb06f1e68dfd31231b2130b54e906a2d465311851
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Scott Wright
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Comment = Struct.new(
|
|
3
|
+
:id,
|
|
4
|
+
:content,
|
|
5
|
+
:item_id,
|
|
6
|
+
:project_id,
|
|
7
|
+
:file_attachment,
|
|
8
|
+
:is_deleted,
|
|
9
|
+
:posted_at,
|
|
10
|
+
:posted_uid,
|
|
11
|
+
:reactions,
|
|
12
|
+
:uids_to_notify,
|
|
13
|
+
:jsw_ignored_keys,
|
|
14
|
+
keyword_init: true
|
|
15
|
+
) do
|
|
16
|
+
include StructCleanup
|
|
17
|
+
|
|
18
|
+
def initialize(*arg_hashes)
|
|
19
|
+
args = arg_hashes.first
|
|
20
|
+
fields_to_date_time(args, %w[posted_at])
|
|
21
|
+
ignore_undefined_keys(args)
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
alias_method :task_id, :item_id
|
|
26
|
+
|
|
27
|
+
def file_attachment?
|
|
28
|
+
file_attachment.blank?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def deleted?
|
|
32
|
+
is_deleted.present?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
alias_method :attachment?, :file_attachment?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
class DateParser
|
|
3
|
+
attr_reader :due_parts, :dates
|
|
4
|
+
|
|
5
|
+
def initialize(due_str)
|
|
6
|
+
due_str.downcase!
|
|
7
|
+
@due_parts = due_str.match(due_regex)
|
|
8
|
+
return if @due_parts.nil?
|
|
9
|
+
@dates = @due_parts[:dates].split(",").map(&:strip)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def parse
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def due_regex
|
|
16
|
+
%r{^(?:ev(?:ery)? # string must start with ev or every
|
|
17
|
+
(?<bang>!)?) # optional bang
|
|
18
|
+
\s+(?<dates>.+?) # all valid strings must have some kind of date
|
|
19
|
+
(?:\s+(?:at\s+)? # the keyword at is optional in front of a time
|
|
20
|
+
(?<time>\d{1,2}(?::\d{1,2})?))? # a time is optional and pretty permissive by personal convention should be hh:mm
|
|
21
|
+
(?:\s+starting\s+(?<starting>.+?))? # optional starting date
|
|
22
|
+
(?:\s+ending\s+(?<ending>.+?))? # optional ending date
|
|
23
|
+
$}x
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/todoist/due.rb
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
RECURRING_REGEX = /ev(?:ery)?\s+(\d+)\s+(minutes|hours|days|weeks|months|years)/i
|
|
3
|
+
HOURS_PER_UNIT = {"minutes" => 1 / 60.0, "hours" => 1.0, "days" => 24.0, "weeks" => 24.0 * 7, "months" => 24.0 * 30, "years" => 24.0 * 365.25}.freeze
|
|
4
|
+
|
|
5
|
+
Due = Struct.new(:date_obj, :date, :lang, :is_recurring, :string, :timezone, keyword_init: true) do
|
|
6
|
+
attr_accessor :orig_date_str
|
|
7
|
+
|
|
8
|
+
def initialize(*arg_hashes)
|
|
9
|
+
if arg_hashes.is_a?(Array)
|
|
10
|
+
arg_hash = arg_hashes.first
|
|
11
|
+
end
|
|
12
|
+
if arg_hash.nil?
|
|
13
|
+
super()
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
@orig_date_str = arg_hash&.dig("date").freeze
|
|
17
|
+
arg_hash[:date_obj] = if arg_hash&.dig("date").nil?
|
|
18
|
+
nil
|
|
19
|
+
elsif arg_hash&.dig("date")&.to_s&.upcase&.include?("T")
|
|
20
|
+
Time.parse(arg_hash["date"]).utc
|
|
21
|
+
else
|
|
22
|
+
Date.parse(arg_hash["date"])
|
|
23
|
+
end
|
|
24
|
+
super(arg_hash)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def blank?
|
|
28
|
+
date.blank? && string.blank?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bump
|
|
32
|
+
if string.blank?
|
|
33
|
+
TodoistClient.logger.error { "You can only bump recurring tasks" }
|
|
34
|
+
return nil
|
|
35
|
+
end
|
|
36
|
+
mm = string.match(RECURRING_REGEX)
|
|
37
|
+
unless mm
|
|
38
|
+
TodoistClient.logger.error { "Cannot parse the string of this due obj: #{string}" }
|
|
39
|
+
return nil
|
|
40
|
+
end
|
|
41
|
+
hours = mm[1].to_i * HOURS_PER_UNIT.fetch(mm[2].downcase)
|
|
42
|
+
if date_obj.is_a?(Date) && hours % 24 == 0
|
|
43
|
+
days = (hours / 24).to_i
|
|
44
|
+
date_today = Todoist.today
|
|
45
|
+
if date_today + days > date_obj
|
|
46
|
+
self.date_obj = date_today + days
|
|
47
|
+
date_obj.to_s
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
bumped = time_at_next_qarter_hour + hours * 3600
|
|
51
|
+
if bumped > date_obj
|
|
52
|
+
self.date_obj = Time.utc(bumped.year, bumped.month, bumped.day, bumped.hour, bumped.min)
|
|
53
|
+
date_obj.strftime("%Y-%m-%dT%H:%M:%S")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def days_until
|
|
59
|
+
(Date.parse(date) - Todoist.today).to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def present?
|
|
63
|
+
!blank?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def recurring?
|
|
67
|
+
is_recurring.present?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_due
|
|
71
|
+
regex = /^(?:ev(?:ery)?(?<bang>!)?)\s+(?<dates>.+?)(?:\s+(?:at\s+)?(?<time>\d{1,2}(?::\d{1,2})?))?(?:\s+starting\s+(?<starting>.+?))?(?:\s+ending\s+(?<ending>.+?))?$/i
|
|
72
|
+
date_parts = string.match(regex)
|
|
73
|
+
date_parts[:dates].split(/,\s/)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def time_at_next_qarter_hour(start_time = nil)
|
|
77
|
+
start_time ||= Todoist.now
|
|
78
|
+
minutes = ((start_time.min + 14) / 15) * 15
|
|
79
|
+
top_of_hour = Time.utc(start_time.year, start_time.month, start_time.day, start_time.hour, 0, 0)
|
|
80
|
+
top_of_hour + minutes * 60
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Label = Struct.new(
|
|
3
|
+
:name,
|
|
4
|
+
:id,
|
|
5
|
+
:color,
|
|
6
|
+
:is_deleted,
|
|
7
|
+
:is_favorite,
|
|
8
|
+
:order,
|
|
9
|
+
:jsw_ignored_keys,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
) do
|
|
12
|
+
include StructCleanup
|
|
13
|
+
|
|
14
|
+
def initialize(*arg_hashes)
|
|
15
|
+
args = arg_hashes.first
|
|
16
|
+
ignore_undefined_keys(args)
|
|
17
|
+
replace_keys(args, {item_order: :order})
|
|
18
|
+
super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def deleted?
|
|
22
|
+
is_deleted.present?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def favorite?
|
|
26
|
+
is_favorite.present?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Project =
|
|
3
|
+
Struct.new(
|
|
4
|
+
:id,
|
|
5
|
+
:name,
|
|
6
|
+
:root_name,
|
|
7
|
+
:description,
|
|
8
|
+
:p_num,
|
|
9
|
+
:access,
|
|
10
|
+
:can_assign_tasks,
|
|
11
|
+
:child_order,
|
|
12
|
+
:color,
|
|
13
|
+
:created_at,
|
|
14
|
+
:creator_uid,
|
|
15
|
+
:default_order,
|
|
16
|
+
:inbox_project,
|
|
17
|
+
:is_archived,
|
|
18
|
+
:is_collapsed,
|
|
19
|
+
:is_deleted,
|
|
20
|
+
:is_favorite,
|
|
21
|
+
:is_frozen,
|
|
22
|
+
:is_shared,
|
|
23
|
+
:parent_id,
|
|
24
|
+
:public_access,
|
|
25
|
+
:role,
|
|
26
|
+
:updated_at,
|
|
27
|
+
:view_style,
|
|
28
|
+
:jsw_ignored_keys,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
) do
|
|
31
|
+
include StructCleanup
|
|
32
|
+
|
|
33
|
+
def initialize(*arg_hashes)
|
|
34
|
+
args = arg_hashes.first
|
|
35
|
+
fields_to_date_time(args, %w[created_at updated_at])
|
|
36
|
+
ignore_undefined_keys(args)
|
|
37
|
+
get_root_name(args)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_root_name(args)
|
|
42
|
+
if (m = args["name"].match(/(?<root>.*?)\s*(?<num>\d)*\s*$/))
|
|
43
|
+
args[:root_name] = m[:root].downcase
|
|
44
|
+
args[:p_num] = [m[:num].to_i, 1].max
|
|
45
|
+
else
|
|
46
|
+
args[:root_name] = args["name"]
|
|
47
|
+
args[:p_num] = 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def inspect(short_view: true)
|
|
52
|
+
if short_view
|
|
53
|
+
{name: name, id: id, root_name: root_name, p_num: p_num}
|
|
54
|
+
else
|
|
55
|
+
super()
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
alias_method :order, :child_order
|
|
60
|
+
|
|
61
|
+
def rules(td_client)
|
|
62
|
+
r = td_client.tasks.find { |t| t.project_id == id && t.content == "PROJECT RULES" }
|
|
63
|
+
return if r.nil?
|
|
64
|
+
begin
|
|
65
|
+
JSON.parse(r.description)
|
|
66
|
+
rescue
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def archived?
|
|
72
|
+
is_archived.present?
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def collapsed
|
|
76
|
+
is_collapsed.present?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def deleted?
|
|
80
|
+
is_deleted.present?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def favorite?
|
|
84
|
+
is_favorite.present?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def frozen?
|
|
88
|
+
is_frozen.present?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def inbox?
|
|
92
|
+
inbox_project.present?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def shared?
|
|
96
|
+
is_shared.present?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def url
|
|
100
|
+
"https://todoist.com/showProject?id=#{id}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Section = Struct.new(:name, :id, :project_id,
|
|
3
|
+
:added_at, :archived_at, :collapsed, :is_archived, :is_deleted, :section_order, :sync_id, :user_id,
|
|
4
|
+
keyword_init: true) do
|
|
5
|
+
def initialize(*arg_hashes)
|
|
6
|
+
arg_hashes.each do |args|
|
|
7
|
+
if args.key?("order")
|
|
8
|
+
args[:section_order] = args.delete("order")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
super
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
alias_method :order, :section_order
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
module StructCleanup
|
|
3
|
+
def fields_to_date_time(args, dt_fields)
|
|
4
|
+
dt_fields.each do |fld|
|
|
5
|
+
if args[fld].present?
|
|
6
|
+
begin
|
|
7
|
+
text_value = args.delete(fld)
|
|
8
|
+
args[fld.to_sym] = Time.parse(text_value).utc
|
|
9
|
+
rescue ArgumentError
|
|
10
|
+
args[fld.to_sym] = text_value
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def ignore_undefined_keys(args)
|
|
17
|
+
args[:jsw_ignored_keys] = []
|
|
18
|
+
args.keys.each do |k|
|
|
19
|
+
if !respond_to?(k)
|
|
20
|
+
args.delete(k)
|
|
21
|
+
args[:jsw_ignored_keys] << k
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def replace_keys(args, replacements)
|
|
27
|
+
replacements.each do |old_key, new_key|
|
|
28
|
+
if args.key?(old_key)
|
|
29
|
+
args[new_key.to_sym] = args.delete(old_key)
|
|
30
|
+
elsif args.key?(old_key.to_s)
|
|
31
|
+
args[new_key.to_sym] = args.delete(old_key.to_s)
|
|
32
|
+
elsif args.key?(old_key.to_sym)
|
|
33
|
+
args[new_key.to_sym] = args.delete(old_key.to_sym)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def properties_to_desc
|
|
39
|
+
self.description = "```~~~begin_yaml~~~\n#{jsw_properties.to_yaml}~~~end_yaml~~~```"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/todoist/sync.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Sync = Struct.new(
|
|
3
|
+
:elapsed,
|
|
4
|
+
:resource_types,
|
|
5
|
+
:stats,
|
|
6
|
+
:sync_token,
|
|
7
|
+
:items,
|
|
8
|
+
:projects,
|
|
9
|
+
:labels,
|
|
10
|
+
:filters,
|
|
11
|
+
:collaborator_states,
|
|
12
|
+
:collaborators,
|
|
13
|
+
:completed_info,
|
|
14
|
+
:day_orders,
|
|
15
|
+
:day_orders_timestamp,
|
|
16
|
+
:due_exceptions,
|
|
17
|
+
:full_sync,
|
|
18
|
+
:incomplete_item_ids,
|
|
19
|
+
:incomplete_project_ids,
|
|
20
|
+
:live_notifications,
|
|
21
|
+
:live_notifications_last_read_id,
|
|
22
|
+
:locations,
|
|
23
|
+
:comments,
|
|
24
|
+
:project_notes,
|
|
25
|
+
:reminders,
|
|
26
|
+
:sections,
|
|
27
|
+
:temp_id_mapping,
|
|
28
|
+
:tooltips,
|
|
29
|
+
:user,
|
|
30
|
+
:user_plan_limits,
|
|
31
|
+
:user_settings,
|
|
32
|
+
:view_options,
|
|
33
|
+
keyword_init: true
|
|
34
|
+
) do
|
|
35
|
+
def version
|
|
36
|
+
:v9
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
alias_method :tasks, :items
|
|
40
|
+
|
|
41
|
+
def insp
|
|
42
|
+
res = {}
|
|
43
|
+
members.each do |mbr|
|
|
44
|
+
val = send(mbr)
|
|
45
|
+
val = ":count: #{val.count}" if val.is_a?(Array)
|
|
46
|
+
res[mbr] = val
|
|
47
|
+
end
|
|
48
|
+
res
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/todoist/task.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
Task = Struct.new(
|
|
3
|
+
:id,
|
|
4
|
+
:project_name,
|
|
5
|
+
:project_id,
|
|
6
|
+
:content,
|
|
7
|
+
:added_at,
|
|
8
|
+
:added_by_uid,
|
|
9
|
+
:assigned_by_uid,
|
|
10
|
+
:checked,
|
|
11
|
+
:child_order,
|
|
12
|
+
:completed_at,
|
|
13
|
+
:day_order,
|
|
14
|
+
:deadline,
|
|
15
|
+
:description,
|
|
16
|
+
:due,
|
|
17
|
+
:duration,
|
|
18
|
+
:is_collapsed,
|
|
19
|
+
:is_deleted,
|
|
20
|
+
:labels,
|
|
21
|
+
:note_count,
|
|
22
|
+
:parent_id,
|
|
23
|
+
:priority,
|
|
24
|
+
:responsible_uid,
|
|
25
|
+
:section_id,
|
|
26
|
+
:updated_at,
|
|
27
|
+
:user_id,
|
|
28
|
+
:jsw_properties,
|
|
29
|
+
:jsw_ignored_keys,
|
|
30
|
+
keyword_init: true
|
|
31
|
+
) do
|
|
32
|
+
include StructCleanup
|
|
33
|
+
|
|
34
|
+
def initialize(*arg_hashes)
|
|
35
|
+
args = arg_hashes.first
|
|
36
|
+
get_properties(args)
|
|
37
|
+
fields_to_date_time(args, %w[added_at updated_at])
|
|
38
|
+
ignore_undefined_keys(args)
|
|
39
|
+
due_to_struct(args)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def due_to_struct(args)
|
|
44
|
+
args["due"] = Due[args["due"]]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
self.due = due.to_h
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def url
|
|
53
|
+
"https://todoist.com/showTask?id=#{id}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def get_properties(args)
|
|
57
|
+
args[:jsw_properties] = {}
|
|
58
|
+
if (jsw_properties_match = args["description"].match(/(.*)```\s*~~~begin_yaml~~~\s*(.*)\s*~~~end_yaml~~~\s*```(.*)/m))
|
|
59
|
+
@desc = [jsw_properties_match[1], jsw_properties_match[3]]
|
|
60
|
+
args[:jsw_properties] = YAML.safe_load(jsw_properties_match[2])
|
|
61
|
+
if args[:jsw_properties].is_a?(Hash)
|
|
62
|
+
args[:jsw_properties].deep_symbolize_keys!
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def properties_string(properties = nil)
|
|
68
|
+
properties ||= jsw_properties
|
|
69
|
+
<<~HEREDOC
|
|
70
|
+
#{@desc&.dig(0)}
|
|
71
|
+
```
|
|
72
|
+
~~~begin_yaml~~~
|
|
73
|
+
#{properties.to_yaml}~~~end_yaml~~~
|
|
74
|
+
```
|
|
75
|
+
#{@desc&.dig(1)}
|
|
76
|
+
HEREDOC
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
class Todoist
|
|
2
|
+
class Utilities
|
|
3
|
+
mon_opts = (Date::MONTHNAMES + Date::ABBR_MONTHNAMES - [nil] + ["sept"]).map { |w| w.downcase.to_s }.join("|")
|
|
4
|
+
dow_opts = (Date::DAYNAMES + Date::ABBR_DAYNAMES - [nil]).map { |w| w.downcase.to_s }.join("|")
|
|
5
|
+
MON_FIRST_REGEX = /(?<mon>#{mon_opts})\s+(?<day>\d{1,2})(?:(?:st\b)|(?:nd\b)|(?:rd\b)|(?:th\b))?/
|
|
6
|
+
MON_LAST_REGEX = /(?<day>\d{1,2})(?:(?:st\b)|(?:nd\b)|(?:rd\b)|(?:th\b))?\s+(?<mon>#{mon_opts})/
|
|
7
|
+
DOW_REGEX = /(?<dow>(?:#{dow_opts})s?)/
|
|
8
|
+
DUE_REGEX = %r{
|
|
9
|
+
^ # start of line
|
|
10
|
+
\s*\bev(?:ery)? # ev or every
|
|
11
|
+
(?<bang>!)? # possible bang in group bang
|
|
12
|
+
\s+
|
|
13
|
+
(?<dates>.*?) # dates in group dates
|
|
14
|
+
\s*
|
|
15
|
+
(?:\bat)?\s* # optional keyword at
|
|
16
|
+
(?<time>\b\d\d:\d\d\b)?\s* # optional time in group time
|
|
17
|
+
(?:(?:\bstarting)\s+(?<start_date>.*?))?\s* # optional starting keyword followed by starting date in group starting
|
|
18
|
+
(?:(?:\bending)\s+(?<end_date>.*?))?\s* # optional ending keyword followed by ending date in group ending
|
|
19
|
+
$ # end of line
|
|
20
|
+
}xi
|
|
21
|
+
ORDINAL_REGEX = /\b(\d+) # first capture group which is a string of digits
|
|
22
|
+
( # start of 2nd capture group
|
|
23
|
+
(?:st\b)|(?:nd\b)|(?:rd\b)|(?:th\b) # a series of anonymous capture groups of two letters
|
|
24
|
+
# any of these letter pairs will match
|
|
25
|
+
)/x
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def normalize_due_string(orig_due_string)
|
|
29
|
+
orig_due_string = orig_due_string.downcase
|
|
30
|
+
mm = orig_due_string.match(DUE_REGEX)
|
|
31
|
+
if mm.nil?
|
|
32
|
+
raise ArgumentError, "Due String not recognized: #{orig_due_string}"
|
|
33
|
+
end
|
|
34
|
+
if mm[:dates]
|
|
35
|
+
dates = mm[:dates].split(/\s*,\s*/).map(&:downcase)
|
|
36
|
+
new_dates = []
|
|
37
|
+
dates.each do |d|
|
|
38
|
+
if (dm = d.match(/(?<num>\d+)\s+weeks?/))
|
|
39
|
+
if dm[:num].to_i <= 7
|
|
40
|
+
d = "#{dm[:num].to_i * 7} days"
|
|
41
|
+
end
|
|
42
|
+
elsif (dm = d.match(/(?<mon>\d{1,2})\/(?<day>\d{1,2})/))
|
|
43
|
+
d = "#{dm[:day]} #{Date::ABBR_MONTHNAMES[dm[:mon].to_i].downcase}"
|
|
44
|
+
elsif (dm = d.match(MON_FIRST_REGEX) || d.match(MON_LAST_REGEX))
|
|
45
|
+
d = "#{dm[:day]} #{dm[:mon][0..2]}"
|
|
46
|
+
elsif (dm = d.match(DOW_REGEX))
|
|
47
|
+
d = d.gsub(dm[:dow], dm[:dow][0..2])
|
|
48
|
+
end
|
|
49
|
+
new_dates << d
|
|
50
|
+
end
|
|
51
|
+
new_dates = new_dates.join(", ")
|
|
52
|
+
new_dates.gsub!(ORDINAL_REGEX, '\1')
|
|
53
|
+
end
|
|
54
|
+
new_due = "ev#{mm[:bang]} #{new_dates}"
|
|
55
|
+
new_due += " at #{mm[:time]}" if mm[:time]
|
|
56
|
+
new_due += " ending #{mm[:end_date]}" if mm[:end_date]
|
|
57
|
+
if new_due != orig_due_string
|
|
58
|
+
new_due.strip
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
require "conveniences"
|
|
2
|
+
require "json"
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "logger"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "date"
|
|
7
|
+
require "time"
|
|
8
|
+
require "rest_requestor"
|
|
9
|
+
|
|
10
|
+
require_relative "todoist_client/version"
|
|
11
|
+
require_relative "todoist/current_time"
|
|
12
|
+
require_relative "todoist/struct_cleanup"
|
|
13
|
+
require_relative "todoist/due"
|
|
14
|
+
require_relative "todoist/file_attachment"
|
|
15
|
+
require_relative "todoist/comment"
|
|
16
|
+
require_relative "todoist/label"
|
|
17
|
+
require_relative "todoist/section"
|
|
18
|
+
require_relative "todoist/project"
|
|
19
|
+
require_relative "todoist/task"
|
|
20
|
+
require_relative "todoist/sync"
|
|
21
|
+
require_relative "todoist/date_parser"
|
|
22
|
+
require_relative "todoist/utilities"
|
|
23
|
+
|
|
24
|
+
class TodoistClient
|
|
25
|
+
DAYS = %i[sun mon tue wed thu fri sat].freeze
|
|
26
|
+
BASE = {
|
|
27
|
+
rest: {v1: "https://api.todoist.com/api/v1/", legacy: "https://api.todoist.com/rest/v2/"},
|
|
28
|
+
sync: {v1: "https://api.todoist.com/api/v1/sync", legacy: "https://api.todoist.com/sync/v9/sync"}
|
|
29
|
+
}.freeze
|
|
30
|
+
AUTO_SCHED_LABELS = ["auto_schedule"]
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
attr_writer :logger
|
|
34
|
+
|
|
35
|
+
def logger
|
|
36
|
+
@logger ||= Logger.new($stdout)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_accessor :jsw_skip_pry, :req, :paged_reqs
|
|
41
|
+
attr_reader :responses, :request_history, :stats_paginated
|
|
42
|
+
|
|
43
|
+
def initialize(bearer_token)
|
|
44
|
+
@auth = "Bearer #{bearer_token}"
|
|
45
|
+
@responses = []
|
|
46
|
+
@req = RestRequestor.new
|
|
47
|
+
@paged_reqs = []
|
|
48
|
+
@request_history = []
|
|
49
|
+
@stats_paginated = []
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def api_compare(path, verb = :get, q_params: {}, j_params: {}, retries: 0, **kwparams)
|
|
53
|
+
r_legacy = api_rest_request(path, verb, q_params:, j_params:, retries:, version: :legacy, **kwparams)
|
|
54
|
+
r_v1 = api_rest_request(path, verb, q_params:, j_params:, retries:, version: :v1, **kwparams)
|
|
55
|
+
{new: r_v1, legacy: r_legacy}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def paginated_request(path, verb = :get, q_params: {}, j_params: {}, retries: 0, **kwparams)
|
|
59
|
+
q_params[:limit] ||= 200
|
|
60
|
+
results = []
|
|
61
|
+
loop do
|
|
62
|
+
req = api_rest_request(path, verb, q_params:, j_params:, retries:, version: :v1, **kwparams)
|
|
63
|
+
q_params[:cursor] = req["next_cursor"]
|
|
64
|
+
@paged_reqs << req
|
|
65
|
+
puts "Path: #{path}, (#{results.count} + #{req["results"].count}), Next Cursor: [#{req["next_cursor"]}]"
|
|
66
|
+
results += req["results"]
|
|
67
|
+
@stats_paginated << {path:, count: results.count, cursor: req["next_cursor"], last_id: results.last&.dig("id")}
|
|
68
|
+
break if req["next_cursor"].blank?
|
|
69
|
+
end
|
|
70
|
+
results
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def api_rest_request(path, verb = :get, q_params: {}, j_params: {}, retries: 0, version: :v1, **kwparams)
|
|
74
|
+
q_params.each { |k, v| q_params.delete(k) if v.blank? }
|
|
75
|
+
if ["get", :get].include?(verb) && q_params["limit"].nil? && q_params[:limit].nil?
|
|
76
|
+
q_params[:limit] = 200
|
|
77
|
+
end
|
|
78
|
+
req_path = "#{BASE[:rest][version]}#{path}"
|
|
79
|
+
@request_history << {path: req_path, verb:, q_params:, j_params: j_params.merge!(kwparams)}
|
|
80
|
+
resp = req.request(req_path, verb, q_params:, j_params: j_params.merge!(kwparams), auth: @auth)
|
|
81
|
+
responses << resp
|
|
82
|
+
resp
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def add_due_date(item_id, project_id)
|
|
86
|
+
proj_root_name = projects.find { |p| p.id == project_id }&.root_name
|
|
87
|
+
rul = rules.find { |r| r[:name] == proj_root_name }
|
|
88
|
+
due_str = if rul
|
|
89
|
+
next_t_date = (next_task_date(rul) || Todoist.now.hour < 16) ? Todoist.today : Todoist.today + 1
|
|
90
|
+
if rul[:repeat_str].present?
|
|
91
|
+
"#{rul[:repeat_str]} starting #{next_t_date}"
|
|
92
|
+
else
|
|
93
|
+
next_t_date
|
|
94
|
+
end
|
|
95
|
+
else
|
|
96
|
+
(Todoist.now.hour < 16) ? Todoist.today : Todoist.today + 1
|
|
97
|
+
end
|
|
98
|
+
update_task(item_id, due_string: due_str, labels: AUTO_SCHED_LABELS)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def add_comment(task_id, content)
|
|
102
|
+
api_rest_request(:comments, :post, j_params: {task_id:, content:})
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def close_task(idd = nil, id: nil)
|
|
106
|
+
id ||= idd
|
|
107
|
+
raise ArgumentError, "You must supply id as an unamed argument or as a named argument" if id.nil?
|
|
108
|
+
api_rest_request("tasks/#{id}/close", :post)
|
|
109
|
+
true
|
|
110
|
+
rescue RestRequestor::StandardError
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def comments(project_id: nil, task_id: nil)
|
|
115
|
+
raise ArgumentError, "project_id or task_id must be supplied" if project_id.nil? && task_id.nil?
|
|
116
|
+
path = if project_id.present?
|
|
117
|
+
"comments?project_id=#{project_id}"
|
|
118
|
+
else
|
|
119
|
+
"comments?task_id=#{task_id}"
|
|
120
|
+
end
|
|
121
|
+
api_request(path).map { |c| build_comment(c) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def create(item, **kwparams)
|
|
125
|
+
path = item.to_s.pluralize
|
|
126
|
+
required = []
|
|
127
|
+
case item
|
|
128
|
+
when :task
|
|
129
|
+
required += %i[content]
|
|
130
|
+
when :section
|
|
131
|
+
required += %i[name project_id]
|
|
132
|
+
when :comment
|
|
133
|
+
required += %i[task_id content]
|
|
134
|
+
kwparams[:task_id] ||= kwparams.delete(:id)
|
|
135
|
+
when :project_comment
|
|
136
|
+
required += %i[project_id content]
|
|
137
|
+
kwparams[:project_id_id] ||= kwparams.delete(:id)
|
|
138
|
+
path = "comments"
|
|
139
|
+
rtn_obj = Todoist::Comment
|
|
140
|
+
when :label, :project
|
|
141
|
+
required += %i[name]
|
|
142
|
+
else
|
|
143
|
+
raise ArgumentError, "Unable to create item for #{item}"
|
|
144
|
+
end
|
|
145
|
+
unless (required - kwparams.keys).empty?
|
|
146
|
+
raise ArgumentError, "You must supply #{required} to create #{item.to_s.pluralize}"
|
|
147
|
+
end
|
|
148
|
+
api_rtn = api_rest_request(path, :post, j_params: kwparams)
|
|
149
|
+
rtn_obj = (rtn_obj || "Todoist::#{item.to_s.camelcase}".constantize)[api_rtn]
|
|
150
|
+
case item
|
|
151
|
+
when :task
|
|
152
|
+
(@tasks || []) << rtn_obj
|
|
153
|
+
when :project
|
|
154
|
+
(@projects || []) << rtn_obj
|
|
155
|
+
end
|
|
156
|
+
rtn_obj
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def delete_task(idd = nil, id: nil)
|
|
160
|
+
id ||= idd
|
|
161
|
+
raise ArgumentError, "You must supply id as an unamed argument or as a named argument" if id.nil?
|
|
162
|
+
api_rest_request("tasks/#{id}", :delete)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def edit_rules
|
|
166
|
+
dflts = {days: [0, 1, 2, 3, 4, 5, 6], look_ahead: 35, repeat_str: "every 35 days", global?: false, repeater?: false}
|
|
167
|
+
rules.each do |rule|
|
|
168
|
+
upd = true
|
|
169
|
+
rule[:obj] = dflts.merge(rule[:obj])
|
|
170
|
+
rule[:obj].delete(:repeater?)
|
|
171
|
+
if upd
|
|
172
|
+
update_task(rule[:t_id], description: rule[:obj].to_json)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def fix_date(tsk)
|
|
178
|
+
mm = tsk.due.date.match(/^(.*T.*)T.*/)
|
|
179
|
+
if mm
|
|
180
|
+
update_task(tsk.id, due_date: mm[1], due_string: tsk.due.string)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def labels(force: false)
|
|
185
|
+
if !force && defined?(@labels)
|
|
186
|
+
@labels
|
|
187
|
+
else
|
|
188
|
+
@labels = paginated_request("labels").map { |l| Todoist::Label[l] }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def load_sample_tasks(filename = nil)
|
|
193
|
+
filename ||= "data/sample_tasks.yml"
|
|
194
|
+
if @loaded&.dig(filename)
|
|
195
|
+
return @loaded[filename]
|
|
196
|
+
end
|
|
197
|
+
@loaded ||= {}
|
|
198
|
+
permitted_classes = [Todoist::Task, Todoist::Due, Symbol, Time, Date]
|
|
199
|
+
@loaded[filename] ||= YAML.load_file(filename, permitted_classes:, aliases: true)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def next_project_id(root_name, create_proj: true, max_tasks: 299)
|
|
203
|
+
root_name = root_name.to_s.downcase.tr("_", " ")
|
|
204
|
+
if root_name == "inbox"
|
|
205
|
+
return projects.find { |p| p.root_name == "inbox" }&.id
|
|
206
|
+
end
|
|
207
|
+
np_id = project_tasks.find { |pt| pt[:root_name] == root_name && pt[:tasks] < max_tasks }&.dig(:id)
|
|
208
|
+
return np_id if np_id.present? || !create_proj
|
|
209
|
+
|
|
210
|
+
next_proj_num = projects.select { |p| p[:root_name] == root_name }.map { |sp| sp.p_num }.max + 1
|
|
211
|
+
root_p = projects.find { |pt| pt[:root_name] == root_name && pt[:p_num] == 1 }
|
|
212
|
+
np = if root_p
|
|
213
|
+
create(:project, name: "#{root_name.titleize} #{next_proj_num}", color: root_p.color)
|
|
214
|
+
else
|
|
215
|
+
create(:project, name: "#{root_name.titleize} #{next_proj_num}")
|
|
216
|
+
end
|
|
217
|
+
projects << np
|
|
218
|
+
np.id
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def next_task_date(options = {})
|
|
222
|
+
if options[:due_str].present?
|
|
223
|
+
if options[:due_str] == "today"
|
|
224
|
+
if Todoist.now.hour > 15
|
|
225
|
+
return (Todoist.today + 1).strftime("%Y-%m-%d")
|
|
226
|
+
else
|
|
227
|
+
return Todoist.today.strftime("%Y-%m-%d")
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
return options[:due_str]
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
t_per_day = tasks_per_day(options.slice(:days_out, :days, :root_name))
|
|
234
|
+
if options[:min_tasks_per_day].present?
|
|
235
|
+
next_date = t_per_day.find { |t| t[:tasks] < options[:min_tasks_per_day] }&.dig(:date)
|
|
236
|
+
return next_date if next_date.present?
|
|
237
|
+
end
|
|
238
|
+
min_tasks = t_per_day.map { |tpd| tpd[:tasks] }.min
|
|
239
|
+
if options[:max_tasks_per_day].present? && min_tasks >= options[:max_tasks_per_day]
|
|
240
|
+
return nil
|
|
241
|
+
end
|
|
242
|
+
t_per_day.find { |t| t[:tasks] == min_tasks }&.dig(:date)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def no_due_date(status = nil)
|
|
246
|
+
status ||= self.status
|
|
247
|
+
status[:items].select { |i| i.dig("due", "date").nil? && i.parent_id.nil? }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def projects(force: false)
|
|
251
|
+
if !force && defined?(@projects)
|
|
252
|
+
@projects
|
|
253
|
+
else
|
|
254
|
+
@projects = paginated_request("projects").map { |p| Todoist::Project[p] }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def project_id(search_name)
|
|
259
|
+
search_name = search_name.to_s.downcase
|
|
260
|
+
proj = projects.find { |p| p.name.downcase == search_name } || projects.find { |p| p.root_name == search_name }
|
|
261
|
+
proj&.id
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def project_name(proj_id, force: false)
|
|
265
|
+
proj_id = proj_id.to_s
|
|
266
|
+
projects(force:).find { |p| p.id == proj_id }&.dig(:name)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def project_root_name(proj_id, force: false)
|
|
270
|
+
proj_id = proj_id.to_s
|
|
271
|
+
projects(force:).find { |p| p.id == proj_id }&.dig(:name_parts, 0)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def project_rules(project_id: nil, root_name: nil)
|
|
275
|
+
if project_id.nil?
|
|
276
|
+
raise ArgumentError, "You must provide project_id OR root_name" if root_name.nil?
|
|
277
|
+
project_id = projects.find { |p| p.root_name == root_name }&.id
|
|
278
|
+
end
|
|
279
|
+
rul = tasks.find { |t| t.labels.include?("project_rules") && t.project_id == project_id }
|
|
280
|
+
return nil if rul.nil?
|
|
281
|
+
JSON.parse(rul.description)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def project_tasks
|
|
285
|
+
projects.map do |proj|
|
|
286
|
+
{root_name: proj.root_name, p_num: proj.p_num, id: proj.id, tasks: tasks(:all).count { |tsk| tsk.project_id == proj.id }}
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def recurring_tasks(include_annual: false)
|
|
291
|
+
tasks.select { |t| t.due.is_recurring }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def reopen_task(idd = nil, id: nil)
|
|
295
|
+
id ||= idd
|
|
296
|
+
raise ArgumentError, "You must supply id as an unamed argument or as a named argument" if id.nil?
|
|
297
|
+
api_rest_request("tasks/#{id}/reopen", :post)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def rules
|
|
301
|
+
tasks.select { |t| t.labels.include?("project_rules") }.map do |rul|
|
|
302
|
+
JSON.parse(rul.description).deep_symbolize_keys.merge({t_id: rul.id, t_name: rul.content})
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def sections(project_id = nil, force: false)
|
|
307
|
+
if force || !defined?(@sections)
|
|
308
|
+
@sections = paginated_request("sections").map { |s| Todoist::Section[s] }
|
|
309
|
+
end
|
|
310
|
+
if project_id.nil?
|
|
311
|
+
@sections
|
|
312
|
+
else
|
|
313
|
+
@sections.select { |s| s.project_id == project_id.to_s }
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def single_task(idd = nil, id: nil, raw: false)
|
|
318
|
+
id ||= idd
|
|
319
|
+
raise ArgumentError, "You must supply id as an unamed argument or as a named argument" if id.nil?
|
|
320
|
+
build_task(api_rest_request("tasks/#{id}"))
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def task_comments(task_id, raw: false)
|
|
324
|
+
resp = api_rest_request(:comments, q_params: {task_id:})
|
|
325
|
+
if raw
|
|
326
|
+
resp
|
|
327
|
+
else
|
|
328
|
+
resp["results"].map { |r| Todoist::Comment[r] }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def task_from_link(link, raw: false)
|
|
333
|
+
resp = api_rest_request("tasks/#{link.split("-")[-1]}")
|
|
334
|
+
return resp if raw
|
|
335
|
+
build_task(resp)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def tasks(incl = :dated, subtasks: nil, force: false)
|
|
339
|
+
tsks = if !force && defined?(@tasks)
|
|
340
|
+
case incl
|
|
341
|
+
when :dated then @tasks.select { |t| !t.due.date.nil? }
|
|
342
|
+
when :all then @tasks
|
|
343
|
+
when :undated then @tasks.select { |t| t.due.date.nil? }
|
|
344
|
+
end
|
|
345
|
+
else
|
|
346
|
+
@tasks = paginated_request("tasks/").map { |t| build_task(t) }
|
|
347
|
+
tasks(incl)
|
|
348
|
+
end
|
|
349
|
+
case subtasks
|
|
350
|
+
when nil then tsks
|
|
351
|
+
when true then tsks.select { |t| t.parent_id.present? }
|
|
352
|
+
when false then tsks.select { |t| t.parent_id.blank? }
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def tasks_by(criteria, value, include_subtasks: false)
|
|
357
|
+
case criteria.to_s.downcase
|
|
358
|
+
when "project"
|
|
359
|
+
project = projects.find { |p| p["name"].downcase == value.to_s.downcase }
|
|
360
|
+
return if project.nil?
|
|
361
|
+
selected_tasks = tasks(:dated).select { |t| t["project_id"] == project["id"] }
|
|
362
|
+
when "root_name"
|
|
363
|
+
proj_ids = projects.select { |p| p.root_name == value.to_s.downcase }.map { |sp| sp.id }
|
|
364
|
+
selected_tasks = tasks(:dated).select { |t| proj_ids.include?(t["project_id"]) }
|
|
365
|
+
when "project_id"
|
|
366
|
+
selected_tasks = tasks(:dated).select { |t| t["project_id"] == value.to_s }
|
|
367
|
+
else
|
|
368
|
+
return
|
|
369
|
+
end
|
|
370
|
+
selected_tasks.select! { |t| t["parent_id"].nil? } unless include_subtasks
|
|
371
|
+
selected_tasks
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def tasks_per_day(options = {})
|
|
375
|
+
days_out = options[:days_out] || 35
|
|
376
|
+
days = options[:days] || [*0..6]
|
|
377
|
+
cur_tasks = if options[:root_name].present?
|
|
378
|
+
tasks_by(:root_name, options[:root_name])
|
|
379
|
+
else
|
|
380
|
+
tasks
|
|
381
|
+
end
|
|
382
|
+
tasks_per_day = (1..days_out).map { |num| {date: (Date.today + num).to_s, tasks: [], wday: (Date.today + num).wday} }
|
|
383
|
+
sorted = cur_tasks&.select { |t| t.due.days_until.between?(1, days_out + 1) }
|
|
384
|
+
&.sort_by { |t| t.due.date }
|
|
385
|
+
&.group_by { |t| t.due.date_obj.to_date.to_s }
|
|
386
|
+
tasks_per_day.each { |task_d| task_d[:tasks] += sorted[task_d[:date]] || [] }
|
|
387
|
+
tasks_per_day.map! { |t| {date: t[:date], tasks: t[:tasks].count, wday: t[:wday]} }
|
|
388
|
+
tasks_per_day.select { |t| days.include?(t[:wday]) }
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def test_task(content: nil, due_string: "today")
|
|
392
|
+
content = "#{content || "Test Task"} - #{Date.today.strftime("%A")}"
|
|
393
|
+
create(:task, content:, due_string:)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def update_comment(comment_id, content)
|
|
397
|
+
api_rest_request("comments/#{comment_id}", :post, j_params: {content:})
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def update_task(task_id, **params)
|
|
401
|
+
raise ArgumentError, "You must provide at least one key / value pair to update a task" if params.empty?
|
|
402
|
+
api_rest_request("tasks/#{task_id}", :post, j_params: params)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# SYNC METHODS
|
|
406
|
+
|
|
407
|
+
def api_sync_request(path = nil, verb = :post, f_hash: {}, commands: [], retries: 0, version: :v1)
|
|
408
|
+
f_hash[:commands] = commands.to_json if commands.present?
|
|
409
|
+
req.request("#{BASE[:sync][version]}#{path}".freeze, verb, f_hash:, auth: @auth)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def delete_duplicates(content, save_ids: [], incl: :all)
|
|
413
|
+
dups = tasks(incl).select { |t| !save_ids.include?(t.id) && t.content == content }.sort_by { |t| t.added_at }
|
|
414
|
+
commands = dups[1..].map { |d| {type: :item_delete, uuid: SecureRandom.uuid, args: {id: d.id}} }
|
|
415
|
+
responses = []
|
|
416
|
+
while commands.any?
|
|
417
|
+
batch = commands.shift(100)
|
|
418
|
+
responses << api_sync_request(commands: batch)
|
|
419
|
+
end
|
|
420
|
+
responses
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def master
|
|
424
|
+
sync.items.select { |i| i.content.match?(/Master\s*$/) }
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def move_item(item_id, **kwargs)
|
|
428
|
+
api_sync_request(commands: [{type: "item_move", uuid: SecureRandom.uuid, args: {id: item_id}.merge(kwargs)}])
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def multi_move(items, **kwargs)
|
|
432
|
+
resp = []
|
|
433
|
+
while items.any?
|
|
434
|
+
commands = []
|
|
435
|
+
99.times do
|
|
436
|
+
commands << {type: "item_move", uuid: SecureRandom.uuid, args: {id: items.pop}.merge(kwargs)}
|
|
437
|
+
end
|
|
438
|
+
resp << api_sync_request(commands:)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# The Sync API has no direct way to unset a section; moving the item out of
|
|
443
|
+
# its project and back in is what clears the section assignment.
|
|
444
|
+
def remove_section(item, holding_project_id:)
|
|
445
|
+
api_sync_request(commands: [
|
|
446
|
+
{type: "item_move", uuid: SecureRandom.uuid, args: {id: item.id, project_id: holding_project_id}},
|
|
447
|
+
{type: "item_move", uuid: SecureRandom.uuid, args: {id: item.id, project_id: item.project_id}}
|
|
448
|
+
])
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def sync(sync_token = "*", force: false, resource_types: nil, version: :v1)
|
|
452
|
+
resource_types = if resource_types.nil?
|
|
453
|
+
%i[filters labels locations projects sync_token stats items comments sections]
|
|
454
|
+
else
|
|
455
|
+
[resource_types].flatten
|
|
456
|
+
end
|
|
457
|
+
if resource_types.include?(:comments)
|
|
458
|
+
resource_types.delete(:comments)
|
|
459
|
+
resource_types << :notes
|
|
460
|
+
end
|
|
461
|
+
if !force && defined?(@sync)
|
|
462
|
+
@sync
|
|
463
|
+
else
|
|
464
|
+
api_sync_request(f_hash: {sync_token:, resource_types: resource_types.to_json}, version:)
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def debug
|
|
469
|
+
binding.pry if !@jsw_skip_pry # standard:disable Lint/Debugger
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# p_rivate
|
|
473
|
+
|
|
474
|
+
def build_task(tsk)
|
|
475
|
+
tsk[:project_name] = projects.find { |p| p.id == tsk["project_id"] }&.name
|
|
476
|
+
Todoist::Task[tsk]
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
def build_struct(struct, hashed_vals)
|
|
480
|
+
struct[hashed_vals]
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def build_comment(cmt)
|
|
484
|
+
cmt[:file_attachment] = Todoist::FileAttachment[cmt.delete("file_attachment") || cmt.delete("attachment") || {}]
|
|
485
|
+
Todoist::Comment[cmt]
|
|
486
|
+
end
|
|
487
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: todoist_rest_client
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Scott Wright
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: conveniences
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: logger
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: rest_requestor
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.4'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.4'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: minitest
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '5.0'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '5.0'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: standard
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.0'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: rake
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '13.0'
|
|
89
|
+
type: :development
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '13.0'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: pry
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.14'
|
|
103
|
+
type: :development
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0.14'
|
|
110
|
+
description: Wraps the Todoist REST v1 and Sync v9 APIs with typed domain objects
|
|
111
|
+
for tasks, projects, labels, sections, and comments, plus scheduling helpers for
|
|
112
|
+
spreading tasks across days and numbered projects.
|
|
113
|
+
email:
|
|
114
|
+
- scott@wrightzone.com
|
|
115
|
+
executables: []
|
|
116
|
+
extensions: []
|
|
117
|
+
extra_rdoc_files: []
|
|
118
|
+
files:
|
|
119
|
+
- LICENSE
|
|
120
|
+
- lib/todoist/comment.rb
|
|
121
|
+
- lib/todoist/current_time.rb
|
|
122
|
+
- lib/todoist/date_parser.rb
|
|
123
|
+
- lib/todoist/due.rb
|
|
124
|
+
- lib/todoist/file_attachment.rb
|
|
125
|
+
- lib/todoist/label.rb
|
|
126
|
+
- lib/todoist/project.rb
|
|
127
|
+
- lib/todoist/section.rb
|
|
128
|
+
- lib/todoist/struct_cleanup.rb
|
|
129
|
+
- lib/todoist/sync.rb
|
|
130
|
+
- lib/todoist/task.rb
|
|
131
|
+
- lib/todoist/utilities.rb
|
|
132
|
+
- lib/todoist_client.rb
|
|
133
|
+
- lib/todoist_client/version.rb
|
|
134
|
+
homepage: https://codeberg.org/jswright61/todoist_client
|
|
135
|
+
licenses:
|
|
136
|
+
- MIT
|
|
137
|
+
metadata: {}
|
|
138
|
+
rdoc_options: []
|
|
139
|
+
require_paths:
|
|
140
|
+
- lib
|
|
141
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
142
|
+
requirements:
|
|
143
|
+
- - ">="
|
|
144
|
+
- !ruby/object:Gem::Version
|
|
145
|
+
version: '3.1'
|
|
146
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
147
|
+
requirements:
|
|
148
|
+
- - ">="
|
|
149
|
+
- !ruby/object:Gem::Version
|
|
150
|
+
version: '0'
|
|
151
|
+
requirements: []
|
|
152
|
+
rubygems_version: 4.0.16
|
|
153
|
+
specification_version: 4
|
|
154
|
+
summary: A Ruby client for the Todoist API
|
|
155
|
+
test_files: []
|