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
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Cli
|
|
5
|
+
# Implements `slidict lint <file>`: diagnoses whether a Markdown/Asciidoc
|
|
6
|
+
# slide deck has a structure that an audience can actually follow (not
|
|
7
|
+
# whether it looks nice). Diagnosis only -- it does not rewrite the file.
|
|
8
|
+
class Lint
|
|
9
|
+
ASCIIDOC_EXTENSIONS = %w[.adoc .asciidoc].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(output:, linter_factory: nil, renderer: Slidict::Lint::Renderer.new)
|
|
12
|
+
@output = output
|
|
13
|
+
@linter_factory = linter_factory || method(:default_linter)
|
|
14
|
+
@renderer = renderer
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(argv)
|
|
18
|
+
options = parse(argv)
|
|
19
|
+
return print_help if options[:help] || options[:path].nil?
|
|
20
|
+
return file_not_found(options[:path]) unless File.exist?(options[:path])
|
|
21
|
+
|
|
22
|
+
run_lint(options)
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
print_usage_error(e)
|
|
25
|
+
rescue Slidict::Lint::Linter::Error, Llm::Client::Error => e
|
|
26
|
+
print_error(e)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run_lint(options)
|
|
32
|
+
config = build_config(options)
|
|
33
|
+
return llm_required unless config.llm_enabled?
|
|
34
|
+
|
|
35
|
+
print_findings(lint(options, config))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lint(options, config)
|
|
39
|
+
@linter_factory.call(config).lint(File.read(options[:path]), format: format_for(options))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def print_usage_error(error)
|
|
43
|
+
print_error(error)
|
|
44
|
+
@output.puts
|
|
45
|
+
print_help
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def print_error(error)
|
|
50
|
+
@output.puts "Error: #{error.message}"
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def parse(argv)
|
|
55
|
+
args = argv.dup
|
|
56
|
+
options = { path: extract_path!(args) }
|
|
57
|
+
parse_options!(args, options)
|
|
58
|
+
options
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def extract_path!(args)
|
|
62
|
+
args.shift unless args.first.to_s.start_with?("-")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_options!(args, options)
|
|
66
|
+
until args.empty?
|
|
67
|
+
case (arg = args.shift)
|
|
68
|
+
when "-h", "--help" then options[:help] = true
|
|
69
|
+
when "--format" then options[:format] = fetch_value!(args, arg)
|
|
70
|
+
when "--llm-base-url" then options[:llm_base_url] = fetch_value!(args, arg)
|
|
71
|
+
when "--llm-api-key" then options[:llm_api_key] = fetch_value!(args, arg)
|
|
72
|
+
when "--llm-model" then options[:llm_model] = fetch_value!(args, arg)
|
|
73
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fetch_value!(args, option)
|
|
79
|
+
value = args.shift
|
|
80
|
+
raise ArgumentError, "#{option} requires a value" if value.nil? || value.start_with?("-")
|
|
81
|
+
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_config(options)
|
|
86
|
+
Config.from_env.merge(
|
|
87
|
+
base_url: options[:llm_base_url],
|
|
88
|
+
api_key: options[:llm_api_key],
|
|
89
|
+
model: options[:llm_model]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def default_linter(config)
|
|
94
|
+
client = Llm::Client.new(base_url: config.base_url, api_key: config.api_key, model: config.model)
|
|
95
|
+
Slidict::Lint::Linter.new(client: client)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def format_for(options)
|
|
99
|
+
return options[:format] if options[:format]
|
|
100
|
+
|
|
101
|
+
ASCIIDOC_EXTENSIONS.include?(File.extname(options[:path]).downcase) ? "asciidoc" : "markdown"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def print_findings(findings)
|
|
105
|
+
@output.puts(findings.empty? ? "No issues found." : @renderer.render(findings))
|
|
106
|
+
0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def file_not_found(path)
|
|
110
|
+
@output.puts "Error: file not found: #{path}"
|
|
111
|
+
1
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def llm_required
|
|
115
|
+
@output.puts "Error: lint requires an LLM endpoint (--llm-base-url or SLIDICT_LLM_BASE_URL)"
|
|
116
|
+
1
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def print_help
|
|
120
|
+
@output.puts <<~HELP
|
|
121
|
+
Usage: slidict lint <file> [options]
|
|
122
|
+
Diagnoses whether a slide deck's structure will land with its audience.
|
|
123
|
+
--format FORMAT markdown or asciidoc (default: auto-detected from extension)
|
|
124
|
+
--llm-base-url URL OpenAI Compatible API base URL (env: SLIDICT_LLM_BASE_URL)
|
|
125
|
+
--llm-api-key KEY API key for the LLM endpoint (env: SLIDICT_LLM_API_KEY)
|
|
126
|
+
--llm-model NAME Model name to request (env: SLIDICT_LLM_MODEL, default: gpt-4o-mini)
|
|
127
|
+
-h, --help Show this help
|
|
128
|
+
HELP
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "erb"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module Cli
|
|
9
|
+
class Serve
|
|
10
|
+
DEFAULT_PUBLIC_DIR = "public"
|
|
11
|
+
|
|
12
|
+
def initialize(public_dir: DEFAULT_PUBLIC_DIR, output: $stdout)
|
|
13
|
+
@public_dir = File.expand_path(public_dir)
|
|
14
|
+
@output = output
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def run(args = [])
|
|
18
|
+
app = build_app
|
|
19
|
+
original_argv = ARGV.dup
|
|
20
|
+
ARGV.replace(args)
|
|
21
|
+
@output.puts "Serving slides from #{@public_dir}"
|
|
22
|
+
app.run!
|
|
23
|
+
0
|
|
24
|
+
ensure
|
|
25
|
+
ARGV.replace(original_argv) if original_argv
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_app
|
|
31
|
+
require "sinatra/base"
|
|
32
|
+
|
|
33
|
+
public_dir = @public_dir
|
|
34
|
+
|
|
35
|
+
Class.new(Sinatra::Base) do
|
|
36
|
+
set :public_folder, public_dir
|
|
37
|
+
set :static, true
|
|
38
|
+
set :show_exceptions, false
|
|
39
|
+
|
|
40
|
+
get "/" do
|
|
41
|
+
@public_dir = public_dir
|
|
42
|
+
@entries = slide_entries(public_dir)
|
|
43
|
+
erb INDEX_TEMPLATE
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
helpers do
|
|
47
|
+
def slide_entries(public_dir)
|
|
48
|
+
return [] unless Dir.exist?(public_dir)
|
|
49
|
+
|
|
50
|
+
Dir.glob(File.join(public_dir, "**", "*")).filter_map do |path|
|
|
51
|
+
next unless File.file?(path)
|
|
52
|
+
|
|
53
|
+
relative = Pathname.new(path).relative_path_from(Pathname.new(public_dir)).to_s
|
|
54
|
+
next unless slide_file?(relative)
|
|
55
|
+
|
|
56
|
+
{ title: title_for(relative), href: "/#{escape_path(relative)}", path: relative }
|
|
57
|
+
end.sort_by { |entry| entry[:path] }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def slide_file?(path)
|
|
61
|
+
File.extname(path).match?(/\A\.(?:adoc|md|markdown)\z/i)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def title_for(path)
|
|
65
|
+
dirname = File.dirname(path)
|
|
66
|
+
basename = File.basename(path, File.extname(path))
|
|
67
|
+
dirname == "." ? basename : dirname
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def escape_path(path)
|
|
71
|
+
# CGI.escape turns spaces into "+", which Rack's static file
|
|
72
|
+
# routing does not unescape back to a literal space. Percent-encode
|
|
73
|
+
# each segment instead so hrefs match the file on disk.
|
|
74
|
+
path.split("/").map { |segment| ERB::Util.url_encode(segment) }.join("/")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def h(text)
|
|
78
|
+
CGI.escapeHTML(text.to_s)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
INDEX_TEMPLATE = <<~ERB.freeze
|
|
85
|
+
<!doctype html>
|
|
86
|
+
<html lang="en">
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="utf-8">
|
|
89
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
90
|
+
<title>Slidict slides</title>
|
|
91
|
+
<style>
|
|
92
|
+
body { font-family: system-ui, sans-serif; margin: 2rem; }
|
|
93
|
+
li { margin: 0.5rem 0; }
|
|
94
|
+
code { color: #555; }
|
|
95
|
+
</style>
|
|
96
|
+
</head>
|
|
97
|
+
<body>
|
|
98
|
+
<h1>Slidict slides</h1>
|
|
99
|
+
<% if @entries.empty? %>
|
|
100
|
+
<p>No slides found in <code><%= h(@public_dir) %></code>.</p>
|
|
101
|
+
<% else %>
|
|
102
|
+
<ul>
|
|
103
|
+
<% @entries.each do |entry| %>
|
|
104
|
+
<li><a href="<%= entry[:href] %>"><%= h(entry[:title]) %></a> <code><%= h(entry[:path]) %></code></li>
|
|
105
|
+
<% end %>
|
|
106
|
+
</ul>
|
|
107
|
+
<% end %>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
ERB
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Slidict
|
|
4
|
+
module Cli
|
|
5
|
+
# Implements the `slidict slides <list|show|create|edit>` subcommands.
|
|
6
|
+
class Slides
|
|
7
|
+
def initialize(output:, credentials: nil, client: nil, reauthenticate: nil)
|
|
8
|
+
@output = output
|
|
9
|
+
@credentials = credentials || External::SlidictIo::Credentials.new
|
|
10
|
+
@client = client
|
|
11
|
+
@reauthenticate = reauthenticate
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(argv)
|
|
15
|
+
options = parse(argv)
|
|
16
|
+
return print_help if options[:help] || options[:subcommand].nil?
|
|
17
|
+
|
|
18
|
+
send(options[:subcommand], options)
|
|
19
|
+
rescue ArgumentError => e
|
|
20
|
+
@output.puts "Error: #{e.message}"
|
|
21
|
+
@output.puts
|
|
22
|
+
print_help
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Creates or edits a draft directly, bypassing argv parsing (and its
|
|
27
|
+
# "looks like a flag" checks, which would reject body text such as
|
|
28
|
+
# YAML frontmatter that happens to start with "-").
|
|
29
|
+
def publish(body:, id: nil, title: nil, body_format: nil, visibility: nil)
|
|
30
|
+
options = { id: id, title: title, body: body, body_format: body_format, visibility: visibility }
|
|
31
|
+
id ? edit(options) : create(options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def parse(argv)
|
|
37
|
+
args = argv.dup
|
|
38
|
+
subcommand = args.shift
|
|
39
|
+
return { help: true } if subcommand.nil? || %w[-h --help].include?(subcommand)
|
|
40
|
+
|
|
41
|
+
case subcommand
|
|
42
|
+
when "list" then parse_list(args)
|
|
43
|
+
when "show" then parse_show(args)
|
|
44
|
+
when "create" then parse_create(args)
|
|
45
|
+
when "edit" then parse_edit(args)
|
|
46
|
+
else raise ArgumentError, "unknown slides command #{subcommand}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_list(args)
|
|
51
|
+
options = { subcommand: :list }
|
|
52
|
+
until args.empty?
|
|
53
|
+
case (arg = args.shift)
|
|
54
|
+
when "--page" then options[:page] = Integer(fetch_value!(args, arg))
|
|
55
|
+
when "-h", "--help" then options[:help] = true
|
|
56
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_show(args)
|
|
63
|
+
id = args.shift
|
|
64
|
+
raise ArgumentError, "show requires a slide id" if id.nil?
|
|
65
|
+
raise ArgumentError, "unknown option #{args.first}" unless args.empty?
|
|
66
|
+
|
|
67
|
+
{ subcommand: :show, id: id }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse_create(args)
|
|
71
|
+
options = { subcommand: :create }
|
|
72
|
+
parse_body_options!(args, options)
|
|
73
|
+
options
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_edit(args)
|
|
77
|
+
id = args.shift
|
|
78
|
+
raise ArgumentError, "edit requires a slide id" if id.nil?
|
|
79
|
+
|
|
80
|
+
options = { subcommand: :edit, id: id }
|
|
81
|
+
parse_body_options!(args, options)
|
|
82
|
+
options
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_body_options!(args, options)
|
|
86
|
+
until args.empty?
|
|
87
|
+
case (arg = args.shift)
|
|
88
|
+
when "--title" then options[:title] = fetch_value!(args, arg)
|
|
89
|
+
when "--body" then options[:body] = fetch_value!(args, arg)
|
|
90
|
+
when "--file" then options[:file] = fetch_value!(args, arg)
|
|
91
|
+
when "--body-format" then options[:body_format] = fetch_value!(args, arg)
|
|
92
|
+
when "--visibility" then options[:visibility] = fetch_value!(args, arg)
|
|
93
|
+
when "-h", "--help" then options[:help] = true
|
|
94
|
+
else raise ArgumentError, "unknown option #{arg}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
raise ArgumentError, "specify only one of --body or --file" if options[:body] && options[:file]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Does not reject values starting with "-": body text or titles may
|
|
101
|
+
# legitimately start with a dash (e.g. YAML frontmatter), and a genuinely
|
|
102
|
+
# missing value still surfaces as an "unknown option" error from the
|
|
103
|
+
# next iteration of the parse loop, or a nil check below.
|
|
104
|
+
def fetch_value!(args, option)
|
|
105
|
+
value = args.shift
|
|
106
|
+
raise ArgumentError, "#{option} requires a value" if value.nil?
|
|
107
|
+
|
|
108
|
+
value
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def list(options)
|
|
112
|
+
with_reauth_retry do
|
|
113
|
+
print_slide_list(client.list(page: options[:page]))
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def show(options)
|
|
119
|
+
with_reauth_retry do
|
|
120
|
+
print_slide_detail(client.show(options[:id]))
|
|
121
|
+
0
|
|
122
|
+
rescue External::SlidictIo::Client::NotFound
|
|
123
|
+
@output.puts "Error: slide not found"
|
|
124
|
+
1
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def create(options)
|
|
129
|
+
body = read_body(options)
|
|
130
|
+
raise ArgumentError, "create requires --body or --file" if body.to_s.strip.empty?
|
|
131
|
+
|
|
132
|
+
submit("Created") do
|
|
133
|
+
client.create(title: options[:title], body: body, body_format: options[:body_format],
|
|
134
|
+
visibility: options[:visibility])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def edit(options)
|
|
139
|
+
submit("Updated") do
|
|
140
|
+
client.update(options[:id], title: options[:title], body: read_body(options),
|
|
141
|
+
body_format: options[:body_format], visibility: options[:visibility])
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def submit(verb)
|
|
146
|
+
with_reauth_retry do
|
|
147
|
+
slide = yield
|
|
148
|
+
@output.puts "#{verb} slide ##{slide["id"]} (draft)"
|
|
149
|
+
print_slide_detail(slide)
|
|
150
|
+
0
|
|
151
|
+
rescue External::SlidictIo::Client::NotFound
|
|
152
|
+
@output.puts "Error: slide not found"
|
|
153
|
+
1
|
|
154
|
+
rescue External::SlidictIo::Client::NotEditable
|
|
155
|
+
@output.puts "Error: this slide is already published. Edit it from the Web UI instead."
|
|
156
|
+
1
|
|
157
|
+
rescue External::SlidictIo::Client::RateLimited
|
|
158
|
+
print_rate_limited
|
|
159
|
+
rescue External::SlidictIo::Client::Unprocessable => e
|
|
160
|
+
print_unprocessable(e)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Shared by list/show/submit: on a 401 from an expired/invalid token,
|
|
165
|
+
# silently re-authenticate once (via the injected reauthenticate flow)
|
|
166
|
+
# and retry the same request before giving up.
|
|
167
|
+
def with_reauth_retry
|
|
168
|
+
reauthenticated = false
|
|
169
|
+
begin
|
|
170
|
+
yield
|
|
171
|
+
rescue External::SlidictIo::Client::Unauthorized => e
|
|
172
|
+
if !reauthenticated && reauthenticate!
|
|
173
|
+
reauthenticated = true
|
|
174
|
+
retry
|
|
175
|
+
end
|
|
176
|
+
print_client_error(e)
|
|
177
|
+
rescue External::SlidictIo::Client::Error => e
|
|
178
|
+
print_client_error(e)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def read_body(options)
|
|
183
|
+
return options[:body] unless options[:file]
|
|
184
|
+
|
|
185
|
+
File.read(options[:file])
|
|
186
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
187
|
+
raise ArgumentError, "could not read #{options[:file]}: #{e.message}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def client
|
|
191
|
+
@client ||= begin
|
|
192
|
+
token = @credentials.read_cli_token
|
|
193
|
+
token = @credentials.read_cli_token if token.nil? && reauthenticate!
|
|
194
|
+
raise ArgumentError, "not authenticated. Run `slidict auth` first." unless token
|
|
195
|
+
|
|
196
|
+
External::SlidictIo::Client.new(access_token: token[:access_token], token_type: token[:token_type])
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Triggers the injected login flow (see Slidict::Cli::App#auth) and clears the
|
|
201
|
+
# memoized client so the next call to `client` picks up the fresh token.
|
|
202
|
+
def reauthenticate!
|
|
203
|
+
return false unless @reauthenticate
|
|
204
|
+
|
|
205
|
+
@client = nil
|
|
206
|
+
@reauthenticate.call.zero?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def print_slide_list(result)
|
|
210
|
+
slides = result["slides"] || []
|
|
211
|
+
if slides.empty?
|
|
212
|
+
@output.puts "No slides found."
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
slides.each do |slide|
|
|
217
|
+
@output.puts "##{slide["id"]} [#{slide["status"]}/#{slide["visibility"]}] " \
|
|
218
|
+
"#{slide["title"]} (updated_at: #{slide["updated_at"]})"
|
|
219
|
+
end
|
|
220
|
+
@output.puts "(more slides available, use --page to see the next page)" if result["has_more"]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def print_slide_detail(slide)
|
|
224
|
+
@output.puts "##{slide["id"]} #{slide["title"]}"
|
|
225
|
+
@output.puts "status: #{slide["status"]} visibility: #{slide["visibility"]} " \
|
|
226
|
+
"updated_at: #{slide["updated_at"]}"
|
|
227
|
+
@output.puts
|
|
228
|
+
@output.puts slide["body"]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def print_rate_limited
|
|
232
|
+
@output.puts "Error: rate limited. Create/edit is limited to once per minute. Wait and try again."
|
|
233
|
+
1
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def print_unprocessable(error)
|
|
237
|
+
@output.puts "Error: #{error.message}"
|
|
238
|
+
error.errors.each { |message| @output.puts " - #{message}" }
|
|
239
|
+
1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def print_client_error(error)
|
|
243
|
+
@output.puts "Error: #{error.message}"
|
|
244
|
+
1
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def print_help
|
|
248
|
+
@output.puts <<~HELP
|
|
249
|
+
Usage: slidict slides <command> [options]
|
|
250
|
+
|
|
251
|
+
Commands:
|
|
252
|
+
list [--page N] List your slides (20 per page)
|
|
253
|
+
show <id> Show a slide's details and body
|
|
254
|
+
create [options] Create a new draft slide
|
|
255
|
+
edit <id> [options] Edit an existing draft slide
|
|
256
|
+
|
|
257
|
+
Create/edit options:
|
|
258
|
+
--title TEXT Slide title
|
|
259
|
+
--body TEXT Slide body text
|
|
260
|
+
--file PATH Read the slide body from a file (instead of --body)
|
|
261
|
+
--body-format FORMAT asciidoc or markdown (default: auto-detected from body)
|
|
262
|
+
--visibility VIS public, unlisted, or group_only (default: public)
|
|
263
|
+
-h, --help Show this help
|
|
264
|
+
|
|
265
|
+
Note: slides are always created/edited as drafts. Publish from the Web UI.
|
|
266
|
+
HELP
|
|
267
|
+
0
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Slidict
|
|
8
|
+
module External
|
|
9
|
+
module SlidictIo
|
|
10
|
+
class Auth
|
|
11
|
+
Error = Class.new(StandardError)
|
|
12
|
+
Pending = Class.new(StandardError)
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://slidict.io"
|
|
15
|
+
|
|
16
|
+
attr_reader :base_url
|
|
17
|
+
|
|
18
|
+
def initialize(base_url: ENV.fetch("SLIDICT_AUTH_BASE_URL", DEFAULT_BASE_URL))
|
|
19
|
+
@base_url = base_url
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def request_device_code
|
|
23
|
+
response = post_json("/api/cli/device/code", { provider: "github" })
|
|
24
|
+
{
|
|
25
|
+
device_code: fetch!(response, "device_code"),
|
|
26
|
+
user_code: fetch!(response, "user_code"),
|
|
27
|
+
verification_uri: response["verification_uri"] || "#{base_url}/cli/activate",
|
|
28
|
+
interval: response.fetch("interval", 5).to_i,
|
|
29
|
+
expires_in: response.fetch("expires_in", 600).to_i
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def poll_token(device_code:)
|
|
34
|
+
response = post_json(
|
|
35
|
+
"/api/cli/device/token",
|
|
36
|
+
{ device_code: device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code" },
|
|
37
|
+
raise_on_http_error: false
|
|
38
|
+
)
|
|
39
|
+
return response if response["access_token"]
|
|
40
|
+
|
|
41
|
+
error = response["error"].to_s
|
|
42
|
+
raise Pending if %w[authorization_pending slow_down].include?(error)
|
|
43
|
+
|
|
44
|
+
raise Error, response["error_description"] || error unless error.empty?
|
|
45
|
+
|
|
46
|
+
raise Error, "token response did not include access_token"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def post_json(path, payload, raise_on_http_error: true)
|
|
52
|
+
uri = URI.join(base_url, path)
|
|
53
|
+
request = Net::HTTP::Post.new(uri)
|
|
54
|
+
request["Accept"] = "application/json"
|
|
55
|
+
request["Content-Type"] = "application/json"
|
|
56
|
+
request.body = JSON.generate(payload)
|
|
57
|
+
|
|
58
|
+
response = Net::HTTP.start(uri.hostname, uri.port,
|
|
59
|
+
use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 30) do |http|
|
|
60
|
+
http.request(request)
|
|
61
|
+
end
|
|
62
|
+
body = response.body.to_s.empty? ? {} : JSON.parse(response.body)
|
|
63
|
+
return body if response.is_a?(Net::HTTPSuccess) || !raise_on_http_error
|
|
64
|
+
|
|
65
|
+
raise Error, body["error_description"] || body["error"] || "HTTP #{response.code}"
|
|
66
|
+
rescue JSON::ParserError => e
|
|
67
|
+
raise Error, "invalid JSON response: #{e.message}"
|
|
68
|
+
rescue SystemCallError, Timeout::Error => e
|
|
69
|
+
raise Error, e.message
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def fetch!(hash, key)
|
|
73
|
+
hash.fetch(key)
|
|
74
|
+
rescue KeyError
|
|
75
|
+
raise Error, "device code response did not include #{key}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|