reclaim 0.2.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/CHANGELOG.md +46 -0
- data/LICENSE.txt +21 -0
- data/README.md +367 -0
- data/bin/reclaim +6 -0
- data/lib/reclaim/cli.rb +297 -0
- data/lib/reclaim/client.rb +337 -0
- data/lib/reclaim/errors.rb +26 -0
- data/lib/reclaim/task.rb +149 -0
- data/lib/reclaim/utils.rb +87 -0
- data/lib/reclaim/version.rb +5 -0
- data/lib/reclaim.rb +57 -0
- metadata +114 -0
data/lib/reclaim/cli.rb
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module Reclaim
|
|
6
|
+
# CLI Interface
|
|
7
|
+
class CLI
|
|
8
|
+
def self.add_task_arguments(parser, options)
|
|
9
|
+
parser.on('--title TITLE', 'Task title') { |v| options[:title] = v }
|
|
10
|
+
parser.on('--due DUE', 'Due date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, or "none" to clear)') do |v|
|
|
11
|
+
options[:due_date] = parse_clearable_date(v)
|
|
12
|
+
end
|
|
13
|
+
parser.on('--priority PRIORITY', ['P1', 'P2', 'P3', 'P4'], 'Task priority') { |v| options[:priority] = v.downcase.to_sym }
|
|
14
|
+
parser.on('--duration DURATION', Float, 'Task duration in hours') { |v| options[:duration] = v }
|
|
15
|
+
parser.on('--min-chunk MIN', Float, 'Minimum chunk size in hours') { |v| options[:min_chunk_size] = v }
|
|
16
|
+
parser.on('--max-chunk MAX', Float, 'Maximum chunk size in hours') { |v| options[:max_chunk_size] = v }
|
|
17
|
+
parser.on('--min-work MIN', Float, 'Minimum work duration in hours') { |v| options[:min_work_duration] = v }
|
|
18
|
+
parser.on('--max-work MAX', Float, 'Maximum work duration in hours') { |v| options[:max_work_duration] = v }
|
|
19
|
+
parser.on('--snooze DATETIME', 'Start after this date/time (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, or "none" to clear)') do |v|
|
|
20
|
+
options[:snooze_until] = parse_clearable_date(v)
|
|
21
|
+
end
|
|
22
|
+
parser.on('--defer DATETIME', 'Start after this date/time (synonym for --snooze, or "none" to clear)') do |v|
|
|
23
|
+
options[:snooze_until] = parse_clearable_date(v)
|
|
24
|
+
end
|
|
25
|
+
parser.on('--start DATETIME', 'Specific start time (YYYY-MM-DDTHH:MM:SS, or "none" to clear)') do |v|
|
|
26
|
+
options[:start] = parse_clearable_date(v)
|
|
27
|
+
end
|
|
28
|
+
parser.on('--time-scheme SCHEME', 'Time scheme ID or name (e.g., "work", "personal", "Work Hours", or UUID)') { |v| options[:time_scheme] = v }
|
|
29
|
+
parser.on('--split [CHUNK_SIZE]', 'Allow task to be split into smaller chunks. Optional: specify min chunk size in hours (e.g. 0.5 for 30min)') do |v|
|
|
30
|
+
options[:allow_splitting] = true
|
|
31
|
+
options[:split_chunk_size] = v.to_f if v && v.to_f > 0
|
|
32
|
+
end
|
|
33
|
+
parser.on('--private PRIVATE', 'Make task private (true/false)') do |v|
|
|
34
|
+
options[:always_private] = case v.downcase
|
|
35
|
+
when 'true', '1', 'yes', 'y' then true
|
|
36
|
+
when 'false', '0', 'no', 'n' then false
|
|
37
|
+
else
|
|
38
|
+
puts "✗ Invalid value for --private. Use true/false"
|
|
39
|
+
exit(1)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
parser.on('--category CATEGORY', 'Event category') { |v| options[:event_category] = v }
|
|
43
|
+
parser.on('--color COLOR', 'Event color') { |v| options[:event_color] = v }
|
|
44
|
+
parser.on('--notes NOTES', 'Task notes/description') { |v| options[:notes] = v }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Parse date values that can be cleared with special keywords
|
|
48
|
+
def self.parse_clearable_date(value)
|
|
49
|
+
return nil if value.nil?
|
|
50
|
+
# Handle special clear keywords
|
|
51
|
+
return nil if ['none', 'clear', 'null', ''].include?(value.downcase.strip)
|
|
52
|
+
# Otherwise return the date string as-is for the API to parse
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.show_help_and_exit(message = nil)
|
|
57
|
+
puts "✗ #{message}" if message
|
|
58
|
+
puts <<~HELP
|
|
59
|
+
Reclaim Task CRUD Operations
|
|
60
|
+
|
|
61
|
+
Usage: reclaim [COMMAND] [OPTIONS]
|
|
62
|
+
|
|
63
|
+
Commands:
|
|
64
|
+
list [FILTER] List tasks (optional filter: active, completed, overdue)
|
|
65
|
+
(default: lists active tasks when no command given)
|
|
66
|
+
create Create a new task (requires --title)
|
|
67
|
+
get TASK_ID Get task details
|
|
68
|
+
update TASK_ID Update a task
|
|
69
|
+
complete TASK_ID Mark task as complete (ARCHIVED status)
|
|
70
|
+
delete TASK_ID Delete a task (permanent deletion)
|
|
71
|
+
list-schemes List available time schemes
|
|
72
|
+
help Show this help message
|
|
73
|
+
|
|
74
|
+
Task Options:
|
|
75
|
+
--title TITLE Task title
|
|
76
|
+
--due DATE Due date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS, or "none" to clear)
|
|
77
|
+
--priority PRIORITY Task priority (P1, P2, P3, P4)
|
|
78
|
+
--duration HOURS Task duration in hours (e.g., 0.25 for 15min, 1.5 for 90min)
|
|
79
|
+
--split [CHUNK_SIZE] Allow task splitting. Optional: min chunk size in hours (default: no splitting)
|
|
80
|
+
--min-chunk HOURS Minimum chunk size in hours (only with --split)
|
|
81
|
+
--max-chunk HOURS Maximum chunk size in hours (only with --split)
|
|
82
|
+
--min-work HOURS Minimum work duration in hours
|
|
83
|
+
--max-work HOURS Maximum work duration in hours
|
|
84
|
+
--defer DATE Start after this date/time (synonym for --snooze, or "none" to clear)
|
|
85
|
+
--snooze DATE Start after this date/time (or "none" to clear)
|
|
86
|
+
--start DATE Specific start time (or "none" to clear)
|
|
87
|
+
--time-scheme SCHEME Time scheme ID or name
|
|
88
|
+
--private BOOL Make task private (true/false)
|
|
89
|
+
--category CATEGORY Event category
|
|
90
|
+
--color COLOR Event color
|
|
91
|
+
--notes TEXT Task notes/description
|
|
92
|
+
|
|
93
|
+
Clearing Dates:
|
|
94
|
+
Use "none", "clear", or "null" as the value to remove a date field.
|
|
95
|
+
Examples:
|
|
96
|
+
reclaim update abc123 --due none # Clear due date
|
|
97
|
+
reclaim update abc123 --defer clear # Clear deferred start date
|
|
98
|
+
reclaim update abc123 --start null # Clear specific start time
|
|
99
|
+
|
|
100
|
+
Time Scheme Aliases:
|
|
101
|
+
work, working hours, business hours → Finds schemes containing 'work'
|
|
102
|
+
personal, off hours, private → Finds schemes containing 'personal'
|
|
103
|
+
|
|
104
|
+
Status Values:
|
|
105
|
+
SCHEDULED, IN_PROGRESS, COMPLETE (still active), ARCHIVED (truly complete)
|
|
106
|
+
|
|
107
|
+
ID Tracking for GTD Integration:
|
|
108
|
+
Store Reclaim task IDs in NEXT.md as [Reclaim:id] for sync operations.
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
reclaim # Lists active tasks (default)
|
|
112
|
+
reclaim list active # Lists active tasks (explicit)
|
|
113
|
+
reclaim list completed # Lists completed tasks
|
|
114
|
+
reclaim create --title "Important Work" --due 2025-08-15 --priority P1 --duration 2
|
|
115
|
+
reclaim create --title "Research" --duration 3 --split 0.5 # Allow splitting with 30min minimum chunks
|
|
116
|
+
reclaim create --title "Deep Work" --duration 4 # No splitting (default)
|
|
117
|
+
reclaim update abc123 --title "Updated Title" --priority P2
|
|
118
|
+
reclaim complete abc123
|
|
119
|
+
reclaim list-schemes
|
|
120
|
+
HELP
|
|
121
|
+
exit(0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.run
|
|
125
|
+
command = ARGV.shift || 'list'
|
|
126
|
+
|
|
127
|
+
# Handle help flag
|
|
128
|
+
if command == '--help' || command == '-h'
|
|
129
|
+
command = 'help'
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# If no command provided, default to listing active tasks
|
|
133
|
+
if command == 'list' && ARGV.empty?
|
|
134
|
+
ARGV.unshift('active')
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
begin
|
|
138
|
+
client = Reclaim::Client.new
|
|
139
|
+
rescue AuthenticationError => e
|
|
140
|
+
puts "✗ #{e.message}"
|
|
141
|
+
exit(1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
case command
|
|
145
|
+
when 'list'
|
|
146
|
+
filter = ARGV.shift
|
|
147
|
+
if filter && !['active', 'completed', 'overdue'].include?(filter)
|
|
148
|
+
show_help_and_exit("Invalid filter '#{filter}'. Valid options: active, completed, overdue")
|
|
149
|
+
end
|
|
150
|
+
list_tasks(client, filter&.to_sym)
|
|
151
|
+
|
|
152
|
+
when 'create'
|
|
153
|
+
options = {}
|
|
154
|
+
parser = OptionParser.new
|
|
155
|
+
add_task_arguments(parser, options)
|
|
156
|
+
parser.parse!(ARGV)
|
|
157
|
+
|
|
158
|
+
show_help_and_exit("Task title is required. Use --title TITLE") if options[:title].nil?
|
|
159
|
+
|
|
160
|
+
create_task(client, options)
|
|
161
|
+
|
|
162
|
+
when 'get'
|
|
163
|
+
task_id = ARGV.shift
|
|
164
|
+
show_help_and_exit("Task ID is required") if task_id.nil?
|
|
165
|
+
get_task(client, task_id)
|
|
166
|
+
|
|
167
|
+
when 'update'
|
|
168
|
+
task_id = ARGV.shift
|
|
169
|
+
show_help_and_exit("Task ID is required") if task_id.nil?
|
|
170
|
+
|
|
171
|
+
options = {}
|
|
172
|
+
parser = OptionParser.new
|
|
173
|
+
add_task_arguments(parser, options)
|
|
174
|
+
parser.parse!(ARGV)
|
|
175
|
+
|
|
176
|
+
show_help_and_exit("No update fields provided") if options.empty?
|
|
177
|
+
update_task(client, task_id, options)
|
|
178
|
+
|
|
179
|
+
when 'complete'
|
|
180
|
+
task_id = ARGV.shift
|
|
181
|
+
show_help_and_exit("Task ID is required") if task_id.nil?
|
|
182
|
+
complete_task(client, task_id)
|
|
183
|
+
|
|
184
|
+
when 'delete'
|
|
185
|
+
task_id = ARGV.shift
|
|
186
|
+
show_help_and_exit("Task ID is required") if task_id.nil?
|
|
187
|
+
delete_task(client, task_id)
|
|
188
|
+
|
|
189
|
+
when 'list-schemes'
|
|
190
|
+
help_aliases = false
|
|
191
|
+
parser = OptionParser.new
|
|
192
|
+
parser.on('--help-aliases', 'Show common aliases for time schemes') { help_aliases = true }
|
|
193
|
+
parser.parse!(ARGV)
|
|
194
|
+
|
|
195
|
+
list_time_schemes(client, help_aliases)
|
|
196
|
+
|
|
197
|
+
when 'help'
|
|
198
|
+
show_help_and_exit
|
|
199
|
+
|
|
200
|
+
else
|
|
201
|
+
show_help_and_exit("Unknown command '#{command}'")
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
puts "✗ Error: #{e.message}"
|
|
206
|
+
exit(1)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# CLI command implementations
|
|
210
|
+
def self.list_tasks(client, filter = nil)
|
|
211
|
+
tasks = client.list_tasks(filter: filter)
|
|
212
|
+
|
|
213
|
+
if tasks.empty?
|
|
214
|
+
puts "No tasks found#{filter ? " matching filter '#{filter}'" : ''}."
|
|
215
|
+
return
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
puts "\nYour Reclaim Tasks#{filter ? " (#{filter})" : ''}:"
|
|
219
|
+
puts '-' * 50
|
|
220
|
+
|
|
221
|
+
tasks.each do |task|
|
|
222
|
+
status_icon = task.completed? ? '✓' : '○'
|
|
223
|
+
due_str = task.due_date ? " (due: #{task.due_date_formatted})" : ''
|
|
224
|
+
|
|
225
|
+
puts "#{status_icon} #{task.title}#{due_str}"
|
|
226
|
+
puts " ID: #{task.id} | Priority: #{task.priority} | Status: #{task.status}"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
puts "\nTotal: #{tasks.length} tasks"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def self.create_task(client, options)
|
|
233
|
+
task = client.create_task(**options)
|
|
234
|
+
puts "✓ Created task: #{task.title} (ID: #{task.id})"
|
|
235
|
+
rescue InvalidRecordError => e
|
|
236
|
+
puts "✗ Error creating task: #{e.message}"
|
|
237
|
+
exit(1)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.get_task(client, task_id)
|
|
241
|
+
task = client.get_task(task_id)
|
|
242
|
+
|
|
243
|
+
puts "\nTask: #{task.title}"
|
|
244
|
+
puts "ID: #{task.id}"
|
|
245
|
+
puts "Priority: #{task.priority}"
|
|
246
|
+
puts "Status: #{task.status}"
|
|
247
|
+
puts "Duration: #{task.duration} hours" if task.duration
|
|
248
|
+
puts "Due: #{task.due_date_formatted}" if task.due_date
|
|
249
|
+
puts "Time Scheme: #{task.time_scheme_id}" if task.time_scheme_id
|
|
250
|
+
puts "Private: #{task.always_private}" if task.always_private
|
|
251
|
+
puts "Category: #{task.event_category}" if task.event_category
|
|
252
|
+
puts "Color: #{task.event_color}" if task.event_color
|
|
253
|
+
puts "Notes: #{task.notes}" if task.notes && !task.notes.empty?
|
|
254
|
+
rescue NotFoundError
|
|
255
|
+
puts "✗ Task #{task_id} not found"
|
|
256
|
+
exit(1)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def self.update_task(client, task_id, options)
|
|
260
|
+
task = client.update_task(task_id, **options)
|
|
261
|
+
puts "✓ Updated task: #{task.title}"
|
|
262
|
+
rescue NotFoundError
|
|
263
|
+
puts "✗ Task #{task_id} not found"
|
|
264
|
+
exit(1)
|
|
265
|
+
rescue InvalidRecordError => e
|
|
266
|
+
puts "✗ Error updating task: #{e.message}"
|
|
267
|
+
exit(1)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def self.complete_task(client, task_id)
|
|
271
|
+
task = client.complete_task(task_id)
|
|
272
|
+
puts "✓ Completed task: #{task.title}"
|
|
273
|
+
rescue NotFoundError
|
|
274
|
+
puts "✗ Task #{task_id} not found"
|
|
275
|
+
exit(1)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.delete_task(client, task_id)
|
|
279
|
+
client.delete_task(task_id)
|
|
280
|
+
puts "✓ Deleted task: #{task_id}"
|
|
281
|
+
rescue NotFoundError
|
|
282
|
+
puts "✗ Task #{task_id} not found"
|
|
283
|
+
exit(1)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def self.list_time_schemes(client, help_aliases = false)
|
|
287
|
+
puts client.format_time_schemes
|
|
288
|
+
|
|
289
|
+
if help_aliases
|
|
290
|
+
puts "\nCommon Aliases:"
|
|
291
|
+
puts "• work, working hours, business hours → Finds schemes containing 'work'"
|
|
292
|
+
puts "• personal, off hours, off-hours, private → Finds schemes containing 'personal'"
|
|
293
|
+
puts "• You can also use partial matches (e.g., 'Work' matches 'Work Hours')"
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'openssl'
|
|
7
|
+
|
|
8
|
+
module Reclaim
|
|
9
|
+
# HTTP client for Reclaim API interactions
|
|
10
|
+
class Client
|
|
11
|
+
BASE_URL = 'https://api.app.reclaim.ai/api'
|
|
12
|
+
|
|
13
|
+
def initialize(token = nil)
|
|
14
|
+
@token = token || ENV['RECLAIM_API_KEY']
|
|
15
|
+
raise AuthenticationError, 'RECLAIM_API_KEY environment variable not set' unless @token
|
|
16
|
+
|
|
17
|
+
@time_schemes_cache = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Create a new task
|
|
21
|
+
def create_task(title:, due_date: nil, priority: :p3, duration: 1.0,
|
|
22
|
+
min_chunk_size: nil, max_chunk_size: nil, min_work_duration: nil,
|
|
23
|
+
max_work_duration: nil, snooze_until: nil, start: nil,
|
|
24
|
+
time_scheme: nil, always_private: nil, event_category: nil,
|
|
25
|
+
event_color: nil, notes: nil, allow_splitting: false, split_chunk_size: nil)
|
|
26
|
+
|
|
27
|
+
# Resolve time scheme if provided
|
|
28
|
+
time_scheme_id = time_scheme ? resolve_time_scheme_id(time_scheme) : nil
|
|
29
|
+
if time_scheme && !time_scheme_id
|
|
30
|
+
raise InvalidRecordError, "Time scheme '#{time_scheme}' not found. Use list_time_schemes to see available options."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Set chunk sizes based on splitting preference
|
|
34
|
+
duration_chunks = (duration * 4).to_i # Convert hours to 15-minute chunks
|
|
35
|
+
if allow_splitting
|
|
36
|
+
# Allow splitting: use provided chunk sizes or defaults
|
|
37
|
+
# Use split_chunk_size if provided, otherwise use min_chunk_size or default
|
|
38
|
+
if split_chunk_size
|
|
39
|
+
min_chunk = (split_chunk_size * 4).to_i
|
|
40
|
+
elsif min_chunk_size
|
|
41
|
+
min_chunk = (min_chunk_size * 4).to_i
|
|
42
|
+
else
|
|
43
|
+
min_chunk = 1 # Default 15 minutes
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
max_chunk = max_chunk_size ? (max_chunk_size * 4).to_i : 12 # Default 3 hours
|
|
47
|
+
else
|
|
48
|
+
# Prevent splitting: set both min and max to full duration
|
|
49
|
+
min_chunk = duration_chunks
|
|
50
|
+
max_chunk = duration_chunks
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
task_data = {
|
|
54
|
+
title: title,
|
|
55
|
+
priority: Task::PRIORITIES[priority] || 'P3',
|
|
56
|
+
timeChunksRequired: duration_chunks,
|
|
57
|
+
eventCategory: event_category || 'WORK',
|
|
58
|
+
eventSubType: 'FOCUS',
|
|
59
|
+
minChunkSize: min_chunk,
|
|
60
|
+
maxChunkSize: max_chunk
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# Add optional fields (using actual API field names)
|
|
64
|
+
task_data[:due] = Utils.format_datetime_for_api(due_date) if due_date
|
|
65
|
+
task_data[:notes] = notes if notes
|
|
66
|
+
# Note: minChunkSize and maxChunkSize are now set above based on allow_splitting
|
|
67
|
+
task_data[:minWorkDuration] = (min_work_duration * 4).to_i if min_work_duration
|
|
68
|
+
task_data[:maxWorkDuration] = (max_work_duration * 4).to_i if max_work_duration
|
|
69
|
+
task_data[:snoozeUntil] = Utils.format_datetime_for_api(snooze_until) if snooze_until
|
|
70
|
+
task_data[:start] = Utils.format_datetime_for_api(start) if start
|
|
71
|
+
task_data[:timeSchemeId] = time_scheme_id if time_scheme_id
|
|
72
|
+
task_data[:alwaysPrivate] = always_private if always_private
|
|
73
|
+
task_data[:eventCategory] = event_category if event_category
|
|
74
|
+
task_data[:eventColor] = event_color if event_color
|
|
75
|
+
|
|
76
|
+
response = make_request(:post, '/tasks', task_data)
|
|
77
|
+
Task.new(response)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# List all tasks with optional filtering
|
|
81
|
+
def list_tasks(filter: nil)
|
|
82
|
+
response = make_request(:get, '/tasks')
|
|
83
|
+
tasks = response.map { |task_data| Task.new(task_data) }
|
|
84
|
+
|
|
85
|
+
# Apply client-side filtering since API doesn't support server-side filtering
|
|
86
|
+
case filter
|
|
87
|
+
when :active
|
|
88
|
+
tasks.select(&:active?)
|
|
89
|
+
when :completed
|
|
90
|
+
tasks.select(&:completed?)
|
|
91
|
+
when :overdue
|
|
92
|
+
tasks.select(&:overdue?)
|
|
93
|
+
else
|
|
94
|
+
tasks
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get a specific task by ID
|
|
99
|
+
def get_task(task_id)
|
|
100
|
+
response = make_request(:get, "/tasks/#{task_id}")
|
|
101
|
+
Task.new(response)
|
|
102
|
+
rescue ApiError => e
|
|
103
|
+
raise NotFoundError, "Task #{task_id} not found" if e.status_code == 404
|
|
104
|
+
|
|
105
|
+
raise
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Update an existing task
|
|
109
|
+
def update_task(task_id, title: nil, notes: nil, priority: nil, due_date: :unset,
|
|
110
|
+
duration: nil, min_chunk_size: nil, max_chunk_size: nil,
|
|
111
|
+
min_work_duration: nil, max_work_duration: nil, snooze_until: :unset,
|
|
112
|
+
start: :unset, time_scheme: nil, always_private: nil,
|
|
113
|
+
event_category: nil, event_color: nil)
|
|
114
|
+
|
|
115
|
+
# Build update data with only provided fields
|
|
116
|
+
# Use :unset as sentinel to distinguish "not provided" from "explicitly nil (clear)"
|
|
117
|
+
update_data = {}
|
|
118
|
+
|
|
119
|
+
update_data[:title] = title if title
|
|
120
|
+
update_data[:notes] = notes if notes
|
|
121
|
+
update_data[:priority] = Task::PRIORITIES[priority] if priority
|
|
122
|
+
|
|
123
|
+
# Handle dates: :unset means not provided, nil means clear, value means set
|
|
124
|
+
if due_date != :unset
|
|
125
|
+
update_data[:due] = due_date.nil? ? nil : Utils.format_datetime_for_api(due_date)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
update_data[:timeChunksRequired] = (duration * 4).to_i if duration
|
|
129
|
+
update_data[:minChunkSize] = (min_chunk_size * 4).to_i if min_chunk_size
|
|
130
|
+
update_data[:maxChunkSize] = (max_chunk_size * 4).to_i if max_chunk_size
|
|
131
|
+
update_data[:minWorkDuration] = (min_work_duration * 4).to_i if min_work_duration
|
|
132
|
+
update_data[:maxWorkDuration] = (max_work_duration * 4).to_i if max_work_duration
|
|
133
|
+
|
|
134
|
+
# Handle snooze_until (deferred date)
|
|
135
|
+
if snooze_until != :unset
|
|
136
|
+
update_data[:snoozeUntil] = snooze_until.nil? ? nil : Utils.format_datetime_for_api(snooze_until)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Handle start date
|
|
140
|
+
if start != :unset
|
|
141
|
+
update_data[:start] = start.nil? ? nil : Utils.format_datetime_for_api(start)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
update_data[:alwaysPrivate] = always_private if always_private
|
|
145
|
+
update_data[:eventCategory] = event_category if event_category
|
|
146
|
+
update_data[:eventColor] = event_color if event_color
|
|
147
|
+
|
|
148
|
+
# Resolve time scheme if provided
|
|
149
|
+
if time_scheme
|
|
150
|
+
time_scheme_id = resolve_time_scheme_id(time_scheme)
|
|
151
|
+
if time_scheme_id
|
|
152
|
+
update_data[:timeSchemeId] = time_scheme_id
|
|
153
|
+
else
|
|
154
|
+
raise InvalidRecordError, "Time scheme '#{time_scheme}' not found"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
raise InvalidRecordError, 'No update fields provided' if update_data.empty?
|
|
159
|
+
|
|
160
|
+
response = make_request(:patch, "/tasks/#{task_id}", update_data)
|
|
161
|
+
Task.new(response)
|
|
162
|
+
rescue ApiError => e
|
|
163
|
+
raise NotFoundError, "Task #{task_id} not found" if e.status_code == 404
|
|
164
|
+
|
|
165
|
+
raise
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Mark a task as complete
|
|
169
|
+
def complete_task(task_id)
|
|
170
|
+
# Use PATCH to update status to ARCHIVED to match Reclaim app behavior
|
|
171
|
+
response = make_request(:patch, "/tasks/#{task_id}", { status: 'ARCHIVED' })
|
|
172
|
+
Task.new(response)
|
|
173
|
+
rescue ApiError => e
|
|
174
|
+
raise NotFoundError, "Task #{task_id} not found" if e.status_code == 404
|
|
175
|
+
|
|
176
|
+
raise
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Delete a task
|
|
180
|
+
def delete_task(task_id)
|
|
181
|
+
make_request(:delete, "/tasks/#{task_id}")
|
|
182
|
+
true
|
|
183
|
+
rescue ApiError => e
|
|
184
|
+
raise NotFoundError, "Task #{task_id} not found" if e.status_code == 404
|
|
185
|
+
|
|
186
|
+
raise
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# List all available time schemes
|
|
190
|
+
def list_time_schemes
|
|
191
|
+
get_time_schemes
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Get formatted list of time schemes for display
|
|
195
|
+
def format_time_schemes
|
|
196
|
+
schemes = list_time_schemes
|
|
197
|
+
return "No time schemes found." if schemes.empty?
|
|
198
|
+
|
|
199
|
+
output = "\nAvailable Time Schemes:\n"
|
|
200
|
+
output += "-" * 50 + "\n"
|
|
201
|
+
|
|
202
|
+
schemes.each do |scheme|
|
|
203
|
+
title = scheme['title'] || 'Untitled'
|
|
204
|
+
scheme_id = scheme['id'] || 'N/A'
|
|
205
|
+
policy_type = scheme['policyType'] || 'N/A'
|
|
206
|
+
|
|
207
|
+
output += "• #{title}\n"
|
|
208
|
+
output += " ID: #{scheme_id}\n"
|
|
209
|
+
output += " Type: #{policy_type}\n\n"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
output += "Usage: time_scheme: \"Work Hours\" or time_scheme: \"work\"\n"
|
|
213
|
+
output
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Make HTTP request to Reclaim API
|
|
219
|
+
def make_request(method, endpoint, data = nil)
|
|
220
|
+
uri = URI("#{BASE_URL}#{endpoint}")
|
|
221
|
+
|
|
222
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
223
|
+
http.use_ssl = true
|
|
224
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
225
|
+
|
|
226
|
+
# Configure certificate store without CRL checking (OpenSSL 3.x compatibility)
|
|
227
|
+
store = OpenSSL::X509::Store.new
|
|
228
|
+
store.set_default_paths
|
|
229
|
+
http.cert_store = store
|
|
230
|
+
|
|
231
|
+
request_class = case method
|
|
232
|
+
when :get then Net::HTTP::Get
|
|
233
|
+
when :post then Net::HTTP::Post
|
|
234
|
+
when :patch then Net::HTTP::Patch
|
|
235
|
+
when :put then Net::HTTP::Put
|
|
236
|
+
when :delete then Net::HTTP::Delete
|
|
237
|
+
else
|
|
238
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
request = request_class.new(uri)
|
|
242
|
+
request['Authorization'] = "Bearer #{@token}"
|
|
243
|
+
request['Content-Type'] = 'application/json'
|
|
244
|
+
request['User-Agent'] = 'Reclaim Ruby Client/1.0'
|
|
245
|
+
|
|
246
|
+
if data && [:post, :patch, :put].include?(method)
|
|
247
|
+
request.body = data.to_json
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
response = http.request(request)
|
|
251
|
+
|
|
252
|
+
case response.code.to_i
|
|
253
|
+
when 200..299
|
|
254
|
+
return true if response.body.nil? || response.body.strip.empty?
|
|
255
|
+
|
|
256
|
+
JSON.parse(response.body)
|
|
257
|
+
when 401
|
|
258
|
+
raise AuthenticationError, 'Invalid or expired API token'
|
|
259
|
+
when 404
|
|
260
|
+
raise ApiError.new('Resource not found', 404, response.body)
|
|
261
|
+
when 422
|
|
262
|
+
error_msg = 'Validation error'
|
|
263
|
+
if response.body
|
|
264
|
+
begin
|
|
265
|
+
error_data = JSON.parse(response.body)
|
|
266
|
+
error_msg = error_data['message'] || error_data['error'] || error_msg
|
|
267
|
+
rescue JSON::ParserError
|
|
268
|
+
# Use default message if JSON parsing fails
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
raise InvalidRecordError, error_msg
|
|
272
|
+
else
|
|
273
|
+
error_msg = "HTTP #{response.code}: #{response.message}"
|
|
274
|
+
raise ApiError.new(error_msg, response.code.to_i, response.body)
|
|
275
|
+
end
|
|
276
|
+
rescue JSON::ParserError => e
|
|
277
|
+
raise ApiError, "Invalid JSON response: #{e.message}"
|
|
278
|
+
rescue StandardError => e
|
|
279
|
+
raise ApiError, "Network error: #{e.message}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Get time schemes with caching
|
|
283
|
+
def get_time_schemes
|
|
284
|
+
return @time_schemes_cache if @time_schemes_cache
|
|
285
|
+
|
|
286
|
+
response = make_request(:get, '/timeschemes')
|
|
287
|
+
@time_schemes_cache = response || []
|
|
288
|
+
rescue StandardError => e
|
|
289
|
+
warn "Error fetching time schemes: #{e.message}"
|
|
290
|
+
[]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Resolve time scheme name to ID with fuzzy matching
|
|
294
|
+
def resolve_time_scheme_id(name_or_id)
|
|
295
|
+
return nil unless name_or_id
|
|
296
|
+
|
|
297
|
+
name_or_id = name_or_id.to_s
|
|
298
|
+
|
|
299
|
+
# If it looks like a UUID, return as-is
|
|
300
|
+
if name_or_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
|
|
301
|
+
return name_or_id
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
schemes = get_time_schemes
|
|
305
|
+
return nil if schemes.empty?
|
|
306
|
+
|
|
307
|
+
name_lower = name_or_id.downcase
|
|
308
|
+
|
|
309
|
+
# Common aliases for quick matching
|
|
310
|
+
aliases = {
|
|
311
|
+
'work' => ['work', 'work hours', 'working hours', 'business hours'],
|
|
312
|
+
'personal' => ['personal', 'personal hours', 'off hours', 'off-hours', 'private']
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
# Check aliases first
|
|
316
|
+
aliases.each do |alias_key, alias_list|
|
|
317
|
+
if alias_list.include?(name_lower)
|
|
318
|
+
# Find scheme that contains the alias key in its title
|
|
319
|
+
scheme = schemes.find do |s|
|
|
320
|
+
s['title']&.downcase&.include?(alias_key)
|
|
321
|
+
end
|
|
322
|
+
return scheme['id'] if scheme
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Direct title matching (exact, case-insensitive)
|
|
327
|
+
scheme = schemes.find { |s| s['title']&.downcase == name_lower }
|
|
328
|
+
return scheme['id'] if scheme
|
|
329
|
+
|
|
330
|
+
# Partial matching (contains)
|
|
331
|
+
scheme = schemes.find { |s| s['title']&.downcase&.include?(name_lower) }
|
|
332
|
+
return scheme['id'] if scheme
|
|
333
|
+
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Reclaim
|
|
4
|
+
# Base exception class for all Reclaim-related errors
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Authentication-related errors
|
|
8
|
+
class AuthenticationError < Error; end
|
|
9
|
+
|
|
10
|
+
# API-related errors (network issues, invalid responses, etc.)
|
|
11
|
+
class ApiError < Error
|
|
12
|
+
attr_reader :status_code, :response_body
|
|
13
|
+
|
|
14
|
+
def initialize(message, status_code = nil, response_body = nil)
|
|
15
|
+
super(message)
|
|
16
|
+
@status_code = status_code
|
|
17
|
+
@response_body = response_body
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Resource not found errors
|
|
22
|
+
class NotFoundError < Error; end
|
|
23
|
+
|
|
24
|
+
# Invalid record/validation errors
|
|
25
|
+
class InvalidRecordError < Error; end
|
|
26
|
+
end
|