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.
- checksums.yaml +4 -4
- data/.github/workflows/changelog.yml +6 -23
- data/.github/workflows/gem-push.yml +38 -6
- data/AGENTS.md +2 -2
- data/README.md +53 -6
- data/lib/slidict/cli/app.rb +336 -0
- data/lib/slidict/cli/lint.rb +133 -0
- data/lib/slidict/cli/serve.rb +113 -0
- data/lib/slidict/cli/slides.rb +271 -0
- data/lib/slidict/external/slidict_io/auth.rb +80 -0
- data/lib/slidict/external/slidict_io/client.rb +126 -0
- data/lib/slidict/external/slidict_io/credentials.rb +43 -0
- data/lib/slidict/lint/finding.rb +10 -0
- data/lib/slidict/lint/linter.rb +22 -0
- data/lib/slidict/lint/renderer.rb +19 -0
- data/lib/slidict/lint/slide_parser.rb +68 -0
- data/lib/slidict/llm/client.rb +214 -0
- data/lib/slidict/output/format.rb +44 -0
- data/lib/slidict/output/renderer.rb +36 -0
- data/lib/slidict/version.rb +1 -1
- data/lib/slidict.rb +14 -7
- metadata +58 -9
- data/lib/slidict/auth_client.rb +0 -75
- data/lib/slidict/cli.rb +0 -266
- data/lib/slidict/credentials.rb +0 -39
- data/lib/slidict/llm_client.rb +0 -144
- data/lib/slidict/markdown_renderer.rb +0 -39
- data/lib/slidict/slides_client.rb +0 -120
- data/lib/slidict/slides_command.rb +0 -253
|
@@ -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
|