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 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,13 @@
1
+ class Todoist
2
+ # Todoist due dates are all interpreted in UTC, regardless of the host
3
+ # machine's local timezone, so "today"/"now" are pinned to UTC here.
4
+ class << self
5
+ def now
6
+ Time.now.utc
7
+ end
8
+
9
+ def today
10
+ now.to_date
11
+ end
12
+ end
13
+ 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
@@ -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,5 @@
1
+ class Todoist
2
+ FileAttachment = Struct.new(:file_name, :file_size, :file_type, :file_url, :resource_type, :upload_state,
3
+ :image, :image_height, :image_width, keyword_init: true) do
4
+ end
5
+ 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
@@ -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
@@ -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,3 @@
1
+ class TodoistClient
2
+ VERSION = "0.1.0"
3
+ 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: []