slidict 0.2.0 → 0.4.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.
@@ -1,120 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "net/http"
5
- require "uri"
6
-
7
- module Slidict
8
- # Talks to the slidict.io CLI slides API using a CLI access token
9
- # (obtained via Slidict::AuthClient / `slidict auth`).
10
- class SlidesClient
11
- class Error < StandardError; end
12
- class Unauthorized < Error; end
13
- class Forbidden < Error; end
14
- class NotFound < Error; end
15
- class NotEditable < Error; end
16
- class RateLimited < Error; end
17
-
18
- # Raised for 422 responses other than "not_editable", carrying the API's validation errors.
19
- class Unprocessable < Error
20
- attr_reader :errors
21
-
22
- def initialize(message, errors: [])
23
- super(message)
24
- @errors = errors
25
- end
26
- end
27
-
28
- DEFAULT_BASE_URL = "https://slidict.io"
29
-
30
- attr_reader :base_url
31
-
32
- def initialize(access_token:, token_type: "Bearer", base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
33
- @access_token = access_token
34
- @token_type = token_type
35
- @base_url = base_url
36
- end
37
-
38
- def list(page: nil)
39
- get_json("/api/cli/slides", query: page ? { page: page } : {})
40
- end
41
-
42
- def show(id)
43
- get_json("/api/cli/slides/#{id}")
44
- end
45
-
46
- def create(body:, title: nil, body_format: nil, visibility: nil)
47
- post_json("/api/cli/slides",
48
- slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
49
- end
50
-
51
- def update(id, body: nil, title: nil, body_format: nil, visibility: nil)
52
- patch_json("/api/cli/slides/#{id}",
53
- slide_payload(title: title, body: body, body_format: body_format, visibility: visibility))
54
- end
55
-
56
- private
57
-
58
- def slide_payload(title:, body:, body_format:, visibility:)
59
- { title: title, body: body, body_format: body_format, visibility: visibility }.compact
60
- end
61
-
62
- def get_json(path, query: {})
63
- uri = URI.join(base_url, path)
64
- uri.query = URI.encode_www_form(query) unless query.empty?
65
- perform(uri, Net::HTTP::Get.new(uri))
66
- end
67
-
68
- def post_json(path, payload)
69
- uri = URI.join(base_url, path)
70
- perform(uri, build_request(Net::HTTP::Post.new(uri), payload))
71
- end
72
-
73
- def patch_json(path, payload)
74
- uri = URI.join(base_url, path)
75
- perform(uri, build_request(Net::HTTP::Patch.new(uri), payload))
76
- end
77
-
78
- def build_request(request, payload)
79
- request["Content-Type"] = "application/json"
80
- request.body = JSON.generate(payload)
81
- request
82
- end
83
-
84
- def perform(uri, request)
85
- request["Accept"] = "application/json"
86
- request["Authorization"] = "#{@token_type} #{@access_token}"
87
-
88
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
89
- http.request(request)
90
- end
91
- body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
92
- handle_response(response, body)
93
- rescue JSON::ParserError => e
94
- raise Error, "invalid JSON response: #{e.message}"
95
- rescue SystemCallError, Timeout::Error => e
96
- raise Error, e.message
97
- end
98
-
99
- ERROR_CLASSES_BY_STATUS = {
100
- "401" => Unauthorized,
101
- "403" => Forbidden,
102
- "404" => NotFound,
103
- "429" => RateLimited
104
- }.freeze
105
-
106
- def handle_response(response, body)
107
- return body if response.is_a?(Net::HTTPSuccess)
108
- return raise_unprocessable(body) if response.code == "422"
109
-
110
- error_class = ERROR_CLASSES_BY_STATUS.fetch(response.code, Error)
111
- raise error_class, body["error_description"] || body["error"] || "HTTP #{response.code}"
112
- end
113
-
114
- def raise_unprocessable(body)
115
- raise NotEditable, "not_editable" if body["error"] == "not_editable"
116
-
117
- raise Unprocessable.new(body["error"] || "unprocessable", errors: Array(body["errors"]))
118
- end
119
- end
120
- end
@@ -1,253 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Slidict
4
- # Implements the `slidict slides <list|show|create|edit>` subcommands.
5
- class SlidesCommand
6
- def initialize(output:, credentials: nil, client: nil, reauthenticate: nil)
7
- @output = output
8
- @credentials = credentials || Credentials.new
9
- @client = client
10
- @reauthenticate = reauthenticate
11
- end
12
-
13
- def run(argv)
14
- options = parse(argv)
15
- return print_help if options[:help] || options[:subcommand].nil?
16
-
17
- send(options[:subcommand], options)
18
- rescue ArgumentError => e
19
- @output.puts "Error: #{e.message}"
20
- @output.puts
21
- print_help
22
- 1
23
- end
24
-
25
- # Creates or edits a draft directly, bypassing argv parsing (and its
26
- # "looks like a flag" checks, which would reject body text such as
27
- # YAML frontmatter that happens to start with "-").
28
- def publish(body:, id: nil, title: nil, body_format: nil, visibility: nil)
29
- options = { id: id, title: title, body: body, body_format: body_format, visibility: visibility }
30
- id ? edit(options) : create(options)
31
- end
32
-
33
- private
34
-
35
- def parse(argv)
36
- args = argv.dup
37
- subcommand = args.shift
38
- return { help: true } if subcommand.nil? || %w[-h --help].include?(subcommand)
39
-
40
- case subcommand
41
- when "list" then parse_list(args)
42
- when "show" then parse_show(args)
43
- when "create" then parse_create(args)
44
- when "edit" then parse_edit(args)
45
- else raise ArgumentError, "unknown slides command #{subcommand}"
46
- end
47
- end
48
-
49
- def parse_list(args)
50
- options = { subcommand: :list }
51
- until args.empty?
52
- case (arg = args.shift)
53
- when "--page" then options[:page] = Integer(fetch_value!(args, arg))
54
- when "-h", "--help" then options[:help] = true
55
- else raise ArgumentError, "unknown option #{arg}"
56
- end
57
- end
58
- options
59
- end
60
-
61
- def parse_show(args)
62
- id = args.shift
63
- raise ArgumentError, "show requires a slide id" if id.nil?
64
- raise ArgumentError, "unknown option #{args.first}" unless args.empty?
65
-
66
- { subcommand: :show, id: id }
67
- end
68
-
69
- def parse_create(args)
70
- options = { subcommand: :create }
71
- parse_body_options!(args, options)
72
- options
73
- end
74
-
75
- def parse_edit(args)
76
- id = args.shift
77
- raise ArgumentError, "edit requires a slide id" if id.nil?
78
-
79
- options = { subcommand: :edit, id: id }
80
- parse_body_options!(args, options)
81
- options
82
- end
83
-
84
- def parse_body_options!(args, options)
85
- until args.empty?
86
- case (arg = args.shift)
87
- when "--title" then options[:title] = fetch_value!(args, arg)
88
- when "--body" then options[:body] = fetch_value!(args, arg)
89
- when "--file" then options[:file] = fetch_value!(args, arg)
90
- when "--body-format" then options[:body_format] = fetch_value!(args, arg)
91
- when "--visibility" then options[:visibility] = fetch_value!(args, arg)
92
- when "-h", "--help" then options[:help] = true
93
- else raise ArgumentError, "unknown option #{arg}"
94
- end
95
- end
96
- raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
97
- end
98
-
99
- def fetch_value!(args, option)
100
- value = args.shift
101
- raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
102
-
103
- value
104
- end
105
-
106
- def list(options)
107
- print_slide_list(client.list(page: options[:page]))
108
- 0
109
- rescue SlidesClient::Error => e
110
- print_client_error(e)
111
- end
112
-
113
- def show(options)
114
- print_slide_detail(client.show(options[:id]))
115
- 0
116
- rescue SlidesClient::NotFound
117
- @output.puts "Error: slide not found"
118
- 1
119
- rescue SlidesClient::Error => e
120
- print_client_error(e)
121
- end
122
-
123
- def create(options)
124
- body = read_body(options)
125
- raise ArgumentError, "create requires --body or --file" if body.to_s.strip.empty?
126
-
127
- submit("Created") do
128
- client.create(title: options[:title], body: body, body_format: options[:body_format],
129
- visibility: options[:visibility])
130
- end
131
- end
132
-
133
- def edit(options)
134
- submit("Updated") do
135
- client.update(options[:id], title: options[:title], body: read_body(options),
136
- body_format: options[:body_format], visibility: options[:visibility])
137
- end
138
- end
139
-
140
- def submit(verb)
141
- reauthenticated = false
142
- begin
143
- slide = yield
144
- @output.puts "#{verb} slide ##{slide["id"]} (draft)"
145
- print_slide_detail(slide)
146
- 0
147
- rescue SlidesClient::NotFound
148
- @output.puts "Error: slide not found"
149
- 1
150
- rescue SlidesClient::NotEditable
151
- @output.puts "Error: this slide is already published. Edit it from the Web UI instead."
152
- 1
153
- rescue SlidesClient::RateLimited
154
- print_rate_limited
155
- rescue SlidesClient::Unprocessable => e
156
- print_unprocessable(e)
157
- rescue SlidesClient::Unauthorized => e
158
- if !reauthenticated && reauthenticate!
159
- reauthenticated = true
160
- retry
161
- end
162
- print_client_error(e)
163
- rescue SlidesClient::Error => e
164
- print_client_error(e)
165
- end
166
- end
167
-
168
- def read_body(options)
169
- return File.read(options[:file]) if options[:file]
170
-
171
- options[:body]
172
- end
173
-
174
- def client
175
- @client ||= begin
176
- token = @credentials.read_cli_token
177
- token = @credentials.read_cli_token if token.nil? && reauthenticate!
178
- raise ArgumentError, "not authenticated. Run `slidict auth` first." unless token
179
-
180
- SlidesClient.new(access_token: token[:access_token], token_type: token[:token_type])
181
- end
182
- end
183
-
184
- # Triggers the injected login flow (see Slidict::CLI#auth) and clears the
185
- # memoized client so the next call to `client` picks up the fresh token.
186
- def reauthenticate!
187
- return false unless @reauthenticate
188
-
189
- @client = nil
190
- @reauthenticate.call.zero?
191
- end
192
-
193
- def print_slide_list(result)
194
- slides = result["slides"] || []
195
- if slides.empty?
196
- @output.puts "No slides found."
197
- return
198
- end
199
-
200
- slides.each do |slide|
201
- @output.puts "##{slide["id"]} [#{slide["status"]}/#{slide["visibility"]}] " \
202
- "#{slide["title"]} (updated_at: #{slide["updated_at"]})"
203
- end
204
- @output.puts "(more slides available, use --page to see the next page)" if result["has_more"]
205
- end
206
-
207
- def print_slide_detail(slide)
208
- @output.puts "##{slide["id"]} #{slide["title"]}"
209
- @output.puts "status: #{slide["status"]} visibility: #{slide["visibility"]} updated_at: #{slide["updated_at"]}"
210
- @output.puts
211
- @output.puts slide["body"]
212
- end
213
-
214
- def print_rate_limited
215
- @output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
216
- 1
217
- end
218
-
219
- def print_unprocessable(error)
220
- @output.puts "Error: #{error.message}"
221
- error.errors.each { |message| @output.puts " - #{message}" }
222
- 1
223
- end
224
-
225
- def print_client_error(error)
226
- @output.puts "Error: #{error.message}"
227
- 1
228
- end
229
-
230
- def print_help
231
- @output.puts <<~HELP
232
- Usage: slidict slides <command> [options]
233
-
234
- Commands:
235
- list [--page N] List your slides (20 per page)
236
- show <id> Show a slide's details and body
237
- create [options] Create a new draft slide
238
- edit <id> [options] Edit an existing draft slide
239
-
240
- Create/edit options:
241
- --title TEXT Slide title
242
- --body TEXT Slide body text
243
- --file PATH Read the slide body from a file (instead of --body)
244
- --body-format FORMAT asciidoc or markdown (default: auto-detected from body)
245
- --visibility VIS public, unlisted, or group_only (default: public)
246
- -h, --help Show this help
247
-
248
- Note: slides are always created/edited as drafts. Publish from the Web UI.
249
- HELP
250
- 0
251
- end
252
- end
253
- end