raindrop 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: 7148b543f6cdcbd94afeec11799f20ed1f8fa157f31273da4e79d6143f6dc4fd
4
+ data.tar.gz: 77696f09d91334cfebd665e24e4dcbb2466a5f350a2db1b2fc54813f631a0d13
5
+ SHA512:
6
+ metadata.gz: 0a36518c713fa9813937a64575dcd491af37ad3c02294b0f7d0617ab4709a816953d506b84c977729431dfd671cc6718fcfee98de6a681a4c3a7c94e100fe41a
7
+ data.tar.gz: 4c450619941ec53ee1b193a4d07643fe2835f3adb9cbaaf78d0bb19aaabea4e36a9da81e4afbcf33fb78c2a02998ac35e2b83b2efdfdaabbea84f405a6d0075a
data/README.md ADDED
@@ -0,0 +1 @@
1
+ # raindrop
data/exe/raindrop ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+
6
+ require "raindrop"
7
+
8
+ exit Raindrop::CLI.new(ARGV).run
@@ -0,0 +1,660 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "json"
5
+ require "optparse"
6
+ require "uri"
7
+
8
+ require_relative "client"
9
+ require_relative "config"
10
+ require_relative "errors"
11
+ require_relative "oauth"
12
+
13
+ module Raindrop
14
+ class CLI
15
+ SUCCESS = 0
16
+ FAILURE = 1
17
+ DEFAULT_SEARCH_LIMIT = 50
18
+ MAX_SEARCH_LIMIT = 50
19
+
20
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, config: nil)
21
+ @argv = argv.dup
22
+ @stdin = stdin
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ @config = config || Config.new
26
+ end
27
+
28
+ def run
29
+ command = @argv.shift
30
+
31
+ case command
32
+ when "auth"
33
+ run_auth(@argv)
34
+ when "config"
35
+ run_config(@argv)
36
+ when "add"
37
+ add(@argv)
38
+ when "search"
39
+ search(@argv)
40
+ when "get"
41
+ get(@argv)
42
+ when "delete"
43
+ delete(@argv)
44
+ when "tags"
45
+ tags(@argv)
46
+ when "collections"
47
+ collections(@argv)
48
+ when "-h", "--help", nil
49
+ print_usage
50
+ SUCCESS
51
+ else
52
+ @stderr.puts "Unknown command: #{command}"
53
+ print_usage(@stderr)
54
+ FAILURE
55
+ end
56
+ rescue OptionParser::ParseError => e
57
+ @stderr.puts e.message
58
+ FAILURE
59
+ rescue Error => e
60
+ @stderr.puts e.message
61
+ FAILURE
62
+ end
63
+
64
+ private
65
+
66
+ def run_auth(argv)
67
+ subcommand = argv.shift
68
+
69
+ case subcommand
70
+ when "login"
71
+ auth_login(argv)
72
+ when "token"
73
+ reject_arguments!(argv)
74
+ raise AuthenticationError, "Test token authentication is not supported. Run `raindrop auth login`."
75
+ when "status"
76
+ auth_status(argv)
77
+ when "logout"
78
+ auth_logout(argv)
79
+ when "-h", "--help", nil
80
+ print_auth_usage
81
+ SUCCESS
82
+ else
83
+ @stderr.puts "Unknown auth command: #{subcommand}"
84
+ print_auth_usage(@stderr)
85
+ FAILURE
86
+ end
87
+ end
88
+
89
+ def run_config(argv)
90
+ subcommand = argv.shift
91
+
92
+ case subcommand
93
+ when "path"
94
+ config_path(argv)
95
+ when nil
96
+ config_show(argv)
97
+ when "-h", "--help"
98
+ print_config_usage
99
+ SUCCESS
100
+ else
101
+ @stderr.puts "Unknown config command: #{subcommand}"
102
+ print_config_usage(@stderr)
103
+ FAILURE
104
+ end
105
+ end
106
+
107
+ def auth_login(argv)
108
+ options = parse_auth_login_options(argv)
109
+ reject_arguments!(argv)
110
+
111
+ oauth = OAuth.new
112
+ code = options.fetch(:code)
113
+ redirect_uri = options.fetch(:redirect_uri) || OAuth::DEFAULT_REDIRECT_URI
114
+ if code.to_s.strip.empty?
115
+ @stdout.puts "Redirect URI:"
116
+ @stdout.puts redirect_uri
117
+ @stdout.puts
118
+ @stdout.puts "Open this URL:"
119
+ @stdout.puts oauth.authorization_url(
120
+ client_id: options.fetch(:client_id),
121
+ redirect_uri: redirect_uri
122
+ )
123
+ @stdout.puts "Waiting for OAuth callback on #{redirect_uri}"
124
+ code = oauth.receive_authorization_code(redirect_uri: redirect_uri)
125
+ end
126
+ raise AuthenticationError, "Authorization code is empty." if code.to_s.strip.empty?
127
+
128
+ payload = oauth.exchange_code(
129
+ client_id: options.fetch(:client_id),
130
+ client_secret: options.fetch(:client_secret),
131
+ redirect_uri: redirect_uri,
132
+ code: code.strip
133
+ )
134
+ @config.save_oauth_token(payload)
135
+ @stdout.puts "OAuth token saved to #{@config.path}"
136
+ SUCCESS
137
+ end
138
+
139
+ def auth_status(argv)
140
+ reject_arguments!(argv)
141
+
142
+ token = @config.access_token
143
+
144
+ if token.empty?
145
+ @stdout.puts "Not authenticated. Run `raindrop auth login`."
146
+ FAILURE
147
+ elsif @config.auth_type != "oauth"
148
+ @stdout.puts "Test token authentication is not supported. Run `raindrop auth login`."
149
+ FAILURE
150
+ else
151
+ @stdout.puts "Authenticated by #{@config.path}"
152
+ SUCCESS
153
+ end
154
+ end
155
+
156
+ def auth_logout(argv)
157
+ reject_arguments!(argv)
158
+
159
+ deleted = @config.delete_access_token
160
+ if deleted
161
+ @stdout.puts "Token removed from #{@config.path}"
162
+ else
163
+ @stdout.puts "No token found in #{@config.path}"
164
+ end
165
+
166
+ SUCCESS
167
+ end
168
+
169
+ def config_path(argv)
170
+ reject_arguments!(argv)
171
+ @stdout.puts @config.path
172
+ SUCCESS
173
+ end
174
+
175
+ def config_show(argv)
176
+ reject_arguments!(argv)
177
+
178
+ type = @config.auth_type
179
+ token = @config.access_token
180
+
181
+ if type.empty? || token.empty?
182
+ @stdout.puts "Auth: not configured"
183
+ elsif type != "oauth"
184
+ @stdout.puts "Auth: unsupported"
185
+ @stdout.puts "Type: #{type}"
186
+ @stdout.puts "Run `raindrop auth login`."
187
+ else
188
+ @stdout.puts "Auth: #{type}"
189
+ @stdout.puts "Access token: [REDACTED]"
190
+ @stdout.puts "Refresh token: #{@config.refresh_token? ? "[REDACTED]" : "not stored"}"
191
+ @stdout.puts "Token type: #{@config.token_type.empty? ? "unknown" : @config.token_type}"
192
+ @stdout.puts "Expires in: #{@config.expires_in || "unknown"}"
193
+ end
194
+
195
+ SUCCESS
196
+ end
197
+
198
+ def add(argv)
199
+ options = parse_add_options(argv)
200
+ link = parse_url(argv.shift)
201
+ reject_arguments!(argv)
202
+
203
+ payload = authenticated_client.create_raindrop(
204
+ link,
205
+ title: options.fetch(:title),
206
+ excerpt: options.fetch(:description),
207
+ note: options.fetch(:note),
208
+ tags: options.fetch(:tags),
209
+ collection_id: options.fetch(:collection_id)
210
+ )
211
+ item = payload.fetch("item", {})
212
+
213
+ if options.fetch(:json)
214
+ print_json_item(item)
215
+ else
216
+ print_raindrop_detail(item)
217
+ end
218
+
219
+ SUCCESS
220
+ end
221
+
222
+ def search(argv)
223
+ options = parse_search_options(argv)
224
+ query = build_search_query(argv, options)
225
+ raise SearchError, "Search query is required." if query.empty? && search_query_required?(options)
226
+
227
+ if options.fetch(:all)
228
+ search_all(
229
+ authenticated_client,
230
+ query,
231
+ collection_id: search_collection_id(options),
232
+ json: options.fetch(:json)
233
+ )
234
+ else
235
+ payload = authenticated_client.search_raindrops(
236
+ query,
237
+ collection_id: search_collection_id(options),
238
+ perpage: options.fetch(:limit)
239
+ )
240
+ print_search_items(payload.fetch("items", []), json: options.fetch(:json))
241
+ end
242
+
243
+ SUCCESS
244
+ end
245
+
246
+ def get(argv)
247
+ options = parse_get_options(argv)
248
+ id = parse_raindrop_id(argv.shift)
249
+ reject_arguments!(argv)
250
+
251
+ payload = authenticated_client.get_raindrop(id)
252
+ item = payload.fetch("item", {})
253
+
254
+ if options.fetch(:json)
255
+ print_json_item(item)
256
+ else
257
+ print_raindrop_detail(item)
258
+ end
259
+
260
+ SUCCESS
261
+ end
262
+
263
+ def delete(argv)
264
+ options = parse_delete_options(argv)
265
+ id = parse_raindrop_id(argv.shift)
266
+ reject_arguments!(argv)
267
+
268
+ payload = authenticated_client.delete_raindrop(id)
269
+ print_delete_result(id, payload, json: options.fetch(:json))
270
+
271
+ SUCCESS
272
+ end
273
+
274
+ def tags(argv)
275
+ reject_arguments!(argv)
276
+
277
+ payload = authenticated_client.tags
278
+ items = payload.fetch("items", [])
279
+
280
+ if items.empty?
281
+ @stdout.puts "No tags found."
282
+ else
283
+ items.each do |item|
284
+ @stdout.puts "#{item["_id"]}\t#{item["count"]}"
285
+ end
286
+ end
287
+
288
+ SUCCESS
289
+ end
290
+
291
+ def collections(argv)
292
+ reject_arguments!(argv)
293
+
294
+ items = authenticated_client.root_collections.fetch("items", []) +
295
+ authenticated_client.child_collections.fetch("items", [])
296
+ items = unique_items_by_id(items)
297
+
298
+ if items.empty?
299
+ @stdout.puts "No collections found."
300
+ else
301
+ items.each do |item|
302
+ @stdout.puts "#{item["_id"]}\t#{item["title"]}\t#{item["count"]}"
303
+ end
304
+ end
305
+
306
+ SUCCESS
307
+ end
308
+
309
+ def authenticated_client
310
+ @authenticated_client ||= begin
311
+ token = @config.access_token
312
+ raise AuthenticationError, "Not authenticated. Run `raindrop auth login`." if token.empty?
313
+ raise AuthenticationError, "Test token authentication is not supported. Run `raindrop auth login`." unless @config.auth_type == "oauth"
314
+
315
+ Client.new(token: token)
316
+ end
317
+ end
318
+
319
+ def parse_auth_login_options(argv)
320
+ options = { client_id: nil, client_secret: nil, redirect_uri: nil, code: nil }
321
+ parser = OptionParser.new do |opts|
322
+ opts.on("--client-id ID") do |client_id|
323
+ options[:client_id] = client_id
324
+ end
325
+
326
+ opts.on("--client-secret SECRET") do |client_secret|
327
+ options[:client_secret] = client_secret
328
+ end
329
+
330
+ opts.on("--redirect-uri URI") do |redirect_uri|
331
+ options[:redirect_uri] = redirect_uri
332
+ end
333
+
334
+ opts.on("--code CODE") do |code|
335
+ options[:code] = code
336
+ end
337
+ end
338
+ parser.parse!(argv)
339
+ validate_auth_login_options!(options)
340
+ options
341
+ end
342
+
343
+ def parse_add_options(argv)
344
+ options = { json: false, title: nil, description: nil, note: nil, tags: [], collection_id: nil }
345
+ parser = OptionParser.new do |opts|
346
+ opts.on("--json") do
347
+ options[:json] = true
348
+ end
349
+
350
+ opts.on("--title TITLE") do |title|
351
+ options[:title] = title
352
+ end
353
+
354
+ opts.on("--description DESCRIPTION") do |description|
355
+ options[:description] = description
356
+ end
357
+
358
+ opts.on("--note NOTE") do |note|
359
+ options[:note] = note
360
+ end
361
+
362
+ opts.on("--tag TAG") do |tag|
363
+ options[:tags] << tag
364
+ end
365
+
366
+ opts.on("--collection ID", Integer) do |collection_id|
367
+ options[:collection_id] = collection_id
368
+ end
369
+ end
370
+ parser.parse!(argv)
371
+ validate_add_options!(options)
372
+ options
373
+ end
374
+
375
+ def parse_get_options(argv)
376
+ options = { json: false }
377
+ parser = OptionParser.new do |opts|
378
+ opts.on("--json") do
379
+ options[:json] = true
380
+ end
381
+ end
382
+ parser.parse!(argv)
383
+ options
384
+ end
385
+
386
+ def parse_delete_options(argv)
387
+ options = { json: false }
388
+ parser = OptionParser.new do |opts|
389
+ opts.on("--json") do
390
+ options[:json] = true
391
+ end
392
+ end
393
+ parser.parse!(argv)
394
+ options
395
+ end
396
+
397
+ def parse_raindrop_id(value)
398
+ raise OptionParser::MissingArgument, "ID" if value.to_s.strip.empty?
399
+
400
+ id = Integer(value, exception: false)
401
+ raise OptionParser::InvalidArgument, value if id.nil? || id <= 0
402
+
403
+ id
404
+ end
405
+
406
+ def parse_url(value)
407
+ raise OptionParser::MissingArgument, "URL" if value.to_s.strip.empty?
408
+
409
+ uri = URI.parse(value)
410
+ return value if uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
411
+
412
+ raise OptionParser::InvalidArgument, value
413
+ rescue URI::InvalidURIError
414
+ raise OptionParser::InvalidArgument, value
415
+ end
416
+
417
+ def validate_add_options!(options)
418
+ validate_optional_text!("title", options.fetch(:title))
419
+ validate_optional_text!("description", options.fetch(:description))
420
+ validate_optional_text!("note", options.fetch(:note))
421
+ raise OptionParser::InvalidArgument, "tag" unless options.fetch(:tags).all? { |tag| !tag.to_s.strip.empty? }
422
+ end
423
+
424
+ def validate_optional_text!(name, value)
425
+ return if value.nil? || !value.to_s.strip.empty?
426
+
427
+ raise OptionParser::InvalidArgument, name
428
+ end
429
+
430
+ def validate_auth_login_options!(options)
431
+ %i[client_id client_secret].each do |name|
432
+ validate_required_text!(name.to_s.tr("_", "-"), options.fetch(name))
433
+ end
434
+ parse_url(options.fetch(:redirect_uri)) unless options.fetch(:redirect_uri).nil?
435
+ end
436
+
437
+ def validate_required_text!(name, value)
438
+ raise OptionParser::MissingArgument, name if value.nil?
439
+ raise OptionParser::InvalidArgument, name if value.to_s.strip.empty?
440
+ end
441
+
442
+ def parse_search_options(argv)
443
+ options = { limit: DEFAULT_SEARCH_LIMIT, all: false, collection_id: nil, json: false, tags: [] }
444
+ limit_option_used = false
445
+ parser = OptionParser.new do |opts|
446
+ opts.on("--all") do
447
+ options[:all] = true
448
+ end
449
+
450
+ opts.on("--json") do
451
+ options[:json] = true
452
+ end
453
+
454
+ opts.on("--collection ID", Integer) do |collection_id|
455
+ options[:collection_id] = collection_id
456
+ end
457
+
458
+ opts.on("--limit LIMIT", Integer) do |limit|
459
+ options[:limit] = limit
460
+ limit_option_used = true
461
+ end
462
+
463
+ opts.on("--tag TAG") do |tag|
464
+ options[:tags] << tag
465
+ end
466
+ end
467
+ parser.parse!(argv)
468
+ validate_search_options!(options, limit_option_used)
469
+ options
470
+ end
471
+
472
+ def build_search_query(argv, options)
473
+ query = argv.join(" ").strip
474
+ tag_query = options.fetch(:tags).map { |tag| format_tag_query(tag) }.join(" ")
475
+ [query, tag_query].reject(&:empty?).join(" ")
476
+ end
477
+
478
+ def validate_search_options!(options, limit_option_used)
479
+ validate_limit!(options.fetch(:limit))
480
+ validate_tags!(options.fetch(:tags))
481
+
482
+ if options.fetch(:all) && limit_option_used
483
+ raise SearchError, "`--all` cannot be used with `--limit`."
484
+ end
485
+ end
486
+
487
+ def search_query_required?(options)
488
+ options.fetch(:collection_id).nil?
489
+ end
490
+
491
+ def search_collection_id(options)
492
+ options.fetch(:collection_id) || 0
493
+ end
494
+
495
+ def validate_limit!(limit)
496
+ return if limit.between?(1, MAX_SEARCH_LIMIT)
497
+
498
+ raise SearchError, "Search limit must be between 1 and #{MAX_SEARCH_LIMIT}."
499
+ end
500
+
501
+ def validate_tags!(tags)
502
+ return if tags.all? { |tag| !tag.to_s.strip.empty? }
503
+
504
+ raise SearchError, "Search tag must not be empty."
505
+ end
506
+
507
+ def format_tag_query(tag)
508
+ tag = tag.strip
509
+ return %(#"#{tag}") if tag.include?(" ")
510
+
511
+ "##{tag}"
512
+ end
513
+
514
+ def search_all(client, query, collection_id:, json:)
515
+ page = 0
516
+ fetched = 0
517
+ found = false
518
+ collected_items = []
519
+
520
+ loop do
521
+ payload = client.search_raindrops(query, collection_id: collection_id, perpage: 50, page: page)
522
+ items = payload.fetch("items", [])
523
+ break if items.empty?
524
+
525
+ if json
526
+ collected_items.concat(items)
527
+ else
528
+ print_search_items(items)
529
+ end
530
+ found = true
531
+
532
+ fetched += items.size
533
+ count = payload["count"].to_i
534
+ break if count.positive? && fetched >= count
535
+
536
+ page += 1
537
+ sleep 1
538
+ end
539
+
540
+ if json
541
+ print_json_items(collected_items)
542
+ else
543
+ @stdout.puts "No raindrops found." unless found
544
+ end
545
+ end
546
+
547
+ def print_search_items(items, json: false)
548
+ return print_json_items(items) if json
549
+
550
+ if items.empty?
551
+ @stdout.puts "No raindrops found."
552
+ else
553
+ items.each do |item|
554
+ id = item["_id"].to_s
555
+ link = item["link"].to_s
556
+ title = item["title"].to_s.strip
557
+ title = link if title.empty?
558
+ @stdout.puts "#{id} #{title}"
559
+ @stdout.puts " #{link}"
560
+ @stdout.puts
561
+ end
562
+ end
563
+ end
564
+
565
+ def print_json_items(items)
566
+ @stdout.puts JSON.generate(items)
567
+ end
568
+
569
+ def print_json_item(item)
570
+ @stdout.puts JSON.generate(item)
571
+ end
572
+
573
+ def print_delete_result(id, payload, json: false)
574
+ if json
575
+ print_json_item(payload)
576
+ else
577
+ @stdout.puts "Deleted raindrop: #{id}"
578
+ end
579
+ end
580
+
581
+ def print_raindrop_detail(item)
582
+ id = item["_id"].to_s
583
+ link = item["link"].to_s
584
+ title = item["title"].to_s.strip
585
+ title = link if title.empty?
586
+ tags = Array(item["tags"]).map(&:to_s)
587
+ created = item["created"].to_s
588
+ updated = item["lastUpdate"].to_s
589
+ excerpt = item["excerpt"].to_s.strip
590
+ note = item["note"].to_s.strip
591
+
592
+ @stdout.puts "ID: #{id}" unless id.empty?
593
+ @stdout.puts "Title: #{title}" unless title.empty?
594
+ @stdout.puts "URL: #{link}" unless link.empty?
595
+ @stdout.puts "Tags: #{tags.join(", ")}" unless tags.empty?
596
+ @stdout.puts "Created: #{created}" unless created.empty?
597
+ @stdout.puts "Updated: #{updated}" unless updated.empty?
598
+
599
+ print_detail_text("Description", excerpt)
600
+ print_detail_text("Note", note)
601
+ end
602
+
603
+ def print_detail_text(label, text)
604
+ return if text.empty?
605
+
606
+ @stdout.puts "#{label}:"
607
+ @stdout.puts text
608
+ end
609
+
610
+ def unique_items_by_id(items)
611
+ items.each_with_object({}) do |item, indexed_items|
612
+ id = item["_id"]
613
+ next if id.nil?
614
+
615
+ indexed_items[id] ||= item
616
+ end.values
617
+ end
618
+
619
+ def reject_arguments!(argv)
620
+ raise OptionParser::InvalidArgument, argv.join(" ") unless argv.empty?
621
+ end
622
+
623
+ def print_usage(io = @stdout)
624
+ io.puts <<~USAGE
625
+ Usage: raindrop <command>
626
+
627
+ Commands:
628
+ add Add a raindrop
629
+ auth Manage authentication
630
+ config Show configuration information
631
+ delete Delete a saved raindrop
632
+ get Show a saved raindrop
633
+ search Search saved raindrops
634
+ tags List tags
635
+ collections
636
+ List collections
637
+ USAGE
638
+ end
639
+
640
+ def print_auth_usage(io = @stdout)
641
+ io.puts <<~USAGE
642
+ Usage: raindrop auth <command>
643
+
644
+ Commands:
645
+ login Login with OAuth
646
+ status Show authentication status
647
+ logout Remove the stored token
648
+ USAGE
649
+ end
650
+
651
+ def print_config_usage(io = @stdout)
652
+ io.puts <<~USAGE
653
+ Usage: raindrop config [command]
654
+
655
+ Commands:
656
+ path Show config file path
657
+ USAGE
658
+ end
659
+ end
660
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ require_relative "errors"
7
+
8
+ module Raindrop
9
+ class Client
10
+ BASE_URL = "https://api.raindrop.io/rest/v1"
11
+
12
+ def initialize(token:, connection: nil)
13
+ @token = token
14
+ @connection = connection || default_connection
15
+ end
16
+
17
+ def search_raindrops(query, collection_id: 0, perpage: 10, page: 0)
18
+ response = @connection.get("raindrops/#{collection_id}") do |request|
19
+ request.params.update(
20
+ "search" => query,
21
+ "perpage" => perpage,
22
+ "page" => page
23
+ )
24
+ end
25
+ handle_response(response)
26
+ rescue Faraday::ConnectionFailed => e
27
+ raise ApiError, "API request failed: #{e.message}"
28
+ end
29
+
30
+ def get_raindrop(id)
31
+ response = @connection.get("raindrop/#{id}")
32
+ handle_response(response)
33
+ rescue Faraday::ConnectionFailed => e
34
+ raise ApiError, "API request failed: #{e.message}"
35
+ end
36
+
37
+ def create_raindrop(link, title: nil, excerpt: nil, note: nil, tags: [], collection_id: nil)
38
+ response = @connection.post("raindrop") do |request|
39
+ request.headers["Content-Type"] = "application/json"
40
+ request.body = JSON.generate(create_raindrop_body(link, title, excerpt, note, tags, collection_id))
41
+ end
42
+ handle_response(response)
43
+ rescue Faraday::ConnectionFailed => e
44
+ raise ApiError, "API request failed: #{e.message}"
45
+ end
46
+
47
+ def delete_raindrop(id)
48
+ response = @connection.delete("raindrop/#{id}")
49
+ handle_response(response)
50
+ rescue Faraday::ConnectionFailed => e
51
+ raise ApiError, "API request failed: #{e.message}"
52
+ end
53
+
54
+ def tags(collection_id: 0)
55
+ response = @connection.get("tags/#{collection_id}")
56
+ handle_response(response)
57
+ rescue Faraday::ConnectionFailed => e
58
+ raise ApiError, "API request failed: #{e.message}"
59
+ end
60
+
61
+ def root_collections
62
+ response = @connection.get("collections")
63
+ handle_response(response)
64
+ rescue Faraday::ConnectionFailed => e
65
+ raise ApiError, "API request failed: #{e.message}"
66
+ end
67
+
68
+ def child_collections
69
+ response = @connection.get("collections/childrens")
70
+ handle_response(response)
71
+ rescue Faraday::ConnectionFailed => e
72
+ raise ApiError, "API request failed: #{e.message}"
73
+ end
74
+
75
+ private
76
+
77
+ def default_connection
78
+ Faraday.new(url: BASE_URL) do |connection|
79
+ connection.headers["Accept"] = "application/json"
80
+ connection.headers["Authorization"] = "Bearer #{@token}"
81
+ end
82
+ end
83
+
84
+ def create_raindrop_body(link, title, excerpt, note, tags, collection_id)
85
+ body = {
86
+ "link" => link,
87
+ "pleaseParse" => {}
88
+ }
89
+ body["title"] = title unless title.to_s.empty?
90
+ body["excerpt"] = excerpt unless excerpt.to_s.empty?
91
+ body["note"] = note unless note.to_s.empty?
92
+ body["tags"] = tags unless tags.empty?
93
+ body["collection"] = { "$id" => collection_id } unless collection_id.nil?
94
+ body
95
+ end
96
+
97
+ def handle_response(response)
98
+ payload = parse_payload(response)
99
+ return payload if response.success?
100
+
101
+ raise ApiError, error_message(response, payload)
102
+ end
103
+
104
+ def parse_payload(response)
105
+ body = response.body.to_s
106
+ return {} if body.empty?
107
+
108
+ JSON.parse(body)
109
+ rescue JSON::ParserError => e
110
+ return {} unless response.success?
111
+
112
+ raise ApiError, "Failed to parse API response: #{e.message}"
113
+ end
114
+
115
+ def error_message(response, payload)
116
+ if response.status == 401
117
+ return "Authentication failed. The stored token may be invalid. Run `raindrop auth login` again."
118
+ end
119
+
120
+ message = payload["errorMessage"] || payload["message"] || response.reason_phrase || "HTTP error"
121
+ "API request failed: #{response.status} #{message}"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ require_relative "errors"
7
+
8
+ module Raindrop
9
+ class Config
10
+ CONFIG_DIR_MODE = 0o700
11
+ CONFIG_FILE_MODE = 0o600
12
+
13
+ attr_reader :path
14
+
15
+ def initialize
16
+ @path = File.expand_path(self.class.default_path)
17
+ end
18
+
19
+ def self.default_path
20
+ config_home = ENV.fetch("XDG_CONFIG_HOME") do
21
+ File.join(Dir.home, ".config")
22
+ end
23
+
24
+ File.join(config_home, "raindrop-cli", "config.yml")
25
+ end
26
+
27
+ def access_token
28
+ data.dig("auth", "access_token").to_s.strip
29
+ end
30
+
31
+ def auth_type
32
+ data.dig("auth", "type").to_s.strip
33
+ end
34
+
35
+ def refresh_token?
36
+ !data.dig("auth", "refresh_token").to_s.strip.empty?
37
+ end
38
+
39
+ def token_type
40
+ data.dig("auth", "token_type").to_s.strip
41
+ end
42
+
43
+ def expires_in
44
+ data.dig("auth", "expires_in")
45
+ end
46
+
47
+ def save_oauth_token(payload)
48
+ access_token = payload["access_token"].to_s.strip
49
+ raise ConfigError, "OAuth response did not include an access token." if access_token.empty?
50
+
51
+ update do |data|
52
+ data["auth"] = {
53
+ "type" => "oauth",
54
+ "access_token" => access_token
55
+ }
56
+ data["auth"]["refresh_token"] = payload["refresh_token"].to_s unless payload["refresh_token"].to_s.empty?
57
+ data["auth"]["token_type"] = payload["token_type"].to_s unless payload["token_type"].to_s.empty?
58
+ data["auth"]["expires_in"] = payload["expires_in"] if payload.key?("expires_in")
59
+ end
60
+
61
+ true
62
+ end
63
+
64
+ def delete_access_token
65
+ return false unless File.file?(@path)
66
+
67
+ data = read_data
68
+ token = data.dig("auth", "access_token").to_s.strip
69
+ return false if token.empty?
70
+
71
+ data.delete("auth")
72
+ write_data(data)
73
+
74
+ true
75
+ end
76
+
77
+ private
78
+
79
+ def data
80
+ return {} unless File.file?(@path)
81
+
82
+ read_data
83
+ end
84
+
85
+ def update
86
+ data = self.data
87
+ yield data
88
+ write_data(data)
89
+ end
90
+
91
+ def read_data
92
+ YAML.safe_load_file(@path, permitted_classes: [], aliases: false) || {}
93
+ rescue Psych::SyntaxError => e
94
+ raise ConfigError, "Failed to read config file: #{e.message}"
95
+ end
96
+
97
+ def write_data(data)
98
+ if data.empty?
99
+ File.delete(@path) if File.file?(@path)
100
+ return
101
+ end
102
+
103
+ ensure_parent_directory!
104
+ File.write(@path, YAML.dump(data), mode: "w", perm: CONFIG_FILE_MODE)
105
+ File.chmod(CONFIG_FILE_MODE, @path)
106
+ end
107
+
108
+ def ensure_parent_directory!
109
+ dir = File.dirname(@path)
110
+ FileUtils.mkdir_p(dir, mode: CONFIG_DIR_MODE)
111
+ File.chmod(CONFIG_DIR_MODE, dir)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindrop
4
+ class Error < StandardError; end
5
+ class AuthenticationError < Error; end
6
+ class ApiError < Error; end
7
+ class ConfigError < Error; end
8
+ class SearchError < Error; end
9
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "socket"
6
+ require "uri"
7
+
8
+ require_relative "errors"
9
+
10
+ module Raindrop
11
+ class OAuth
12
+ BASE_URL = "https://api.raindrop.io/v1"
13
+ AUTHORIZATION_URL = "#{BASE_URL}/oauth/authorize"
14
+ DEFAULT_REDIRECT_URI = "http://127.0.0.1:42813/callback"
15
+
16
+ def initialize(connection: nil)
17
+ @connection = connection || default_connection
18
+ end
19
+
20
+ def authorization_url(client_id:, redirect_uri:)
21
+ query = URI.encode_www_form(
22
+ "response_type" => "code",
23
+ "client_id" => client_id,
24
+ "redirect_uri" => redirect_uri
25
+ )
26
+ "#{AUTHORIZATION_URL}?#{query}"
27
+ end
28
+
29
+ def exchange_code(client_id:, client_secret:, redirect_uri:, code:)
30
+ response = @connection.post("oauth/access_token") do |request|
31
+ request.headers["Content-Type"] = "application/json"
32
+ request.body = JSON.generate(
33
+ "grant_type" => "authorization_code",
34
+ "code" => code,
35
+ "client_id" => client_id,
36
+ "client_secret" => client_secret,
37
+ "redirect_uri" => redirect_uri
38
+ )
39
+ end
40
+ handle_response(response)
41
+ rescue Faraday::ConnectionFailed => e
42
+ raise ApiError, "OAuth request failed: #{e.message}"
43
+ end
44
+
45
+ def receive_authorization_code(redirect_uri:)
46
+ redirect = URI.parse(redirect_uri)
47
+ server = TCPServer.new(redirect.host, redirect.port)
48
+ socket = server.accept
49
+ request_line = socket.gets.to_s
50
+ code = authorization_code_from_request(request_line, expected_path: redirect.path)
51
+ write_callback_response(socket, "Authentication complete. You can close this window.")
52
+ code
53
+ ensure
54
+ socket&.close
55
+ server&.close
56
+ end
57
+
58
+ private
59
+
60
+ def default_connection
61
+ Faraday.new(url: BASE_URL) do |connection|
62
+ connection.headers["Accept"] = "application/json"
63
+ end
64
+ end
65
+
66
+ def handle_response(response)
67
+ payload = parse_payload(response)
68
+ return payload if response.success?
69
+
70
+ message = payload["error"] || payload["errorMessage"] || payload["message"] || response.reason_phrase || "HTTP error"
71
+ raise ApiError, "OAuth request failed: #{response.status} #{message}"
72
+ end
73
+
74
+ def parse_payload(response)
75
+ body = response.body.to_s
76
+ return {} if body.empty?
77
+
78
+ JSON.parse(body)
79
+ rescue JSON::ParserError => e
80
+ return {} unless response.success?
81
+
82
+ raise ApiError, "Failed to parse OAuth response: #{e.message}"
83
+ end
84
+
85
+ def authorization_code_from_request(request_line, expected_path:)
86
+ target = request_line.split[1].to_s
87
+ raise AuthenticationError, "Authorization code is empty." if target.empty?
88
+
89
+ uri = URI.parse(target)
90
+ raise AuthenticationError, "Unexpected OAuth callback path: #{uri.path}" unless uri.path == expected_path
91
+
92
+ query = URI.decode_www_form(uri.query.to_s).to_h
93
+ raise AuthenticationError, "OAuth authorization failed: #{query.fetch("error")}" if query.key?("error")
94
+
95
+ code = query.fetch("code", "").to_s.strip
96
+ raise AuthenticationError, "Authorization code is empty." if code.empty?
97
+
98
+ code
99
+ rescue URI::InvalidURIError
100
+ raise AuthenticationError, "Authorization code is empty."
101
+ end
102
+
103
+ def write_callback_response(socket, body)
104
+ socket.write "HTTP/1.1 200 OK\r\n"
105
+ socket.write "Content-Type: text/plain; charset=utf-8\r\n"
106
+ socket.write "Content-Length: #{body.bytesize}\r\n"
107
+ socket.write "Connection: close\r\n"
108
+ socket.write "\r\n"
109
+ socket.write body
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindrop
4
+ VERSION = "0.1.0"
5
+ end
data/lib/raindrop.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "raindrop/cli"
4
+ require_relative "raindrop/client"
5
+ require_relative "raindrop/oauth"
6
+ require_relative "raindrop/version"
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: raindrop
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - utakaha
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: faraday
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '3.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '2.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '3.0'
32
+ description: A small Ruby command line client for searching and managing Raindrop.io
33
+ bookmarks.
34
+ executables:
35
+ - raindrop
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - README.md
40
+ - exe/raindrop
41
+ - lib/raindrop.rb
42
+ - lib/raindrop/cli.rb
43
+ - lib/raindrop/client.rb
44
+ - lib/raindrop/config.rb
45
+ - lib/raindrop/errors.rb
46
+ - lib/raindrop/oauth.rb
47
+ - lib/raindrop/version.rb
48
+ homepage: https://github.com/utakaha/raindrop.rb
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ source_code_uri: https://github.com/utakaha/raindrop.rb
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.1'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.6
68
+ specification_version: 4
69
+ summary: Command line client for Raindrop.io
70
+ test_files: []