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 +7 -0
- data/README.md +1 -0
- data/exe/raindrop +8 -0
- data/lib/raindrop/cli.rb +660 -0
- data/lib/raindrop/client.rb +124 -0
- data/lib/raindrop/config.rb +114 -0
- data/lib/raindrop/errors.rb +9 -0
- data/lib/raindrop/oauth.rb +112 -0
- data/lib/raindrop/version.rb +5 -0
- data/lib/raindrop.rb +6 -0
- metadata +70 -0
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
data/lib/raindrop/cli.rb
ADDED
|
@@ -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,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
|
data/lib/raindrop.rb
ADDED
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: []
|