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.
@@ -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